Explorar o código

Merge pull request #10400 from growilabs/master

Release v7.3.4
mergify[bot] hai 5 meses
pai
achega
910b7b67a2
Modificáronse 100 ficheiros con 5851 adicións e 4399 borrados
  1. 398 0
      .serena/memories/apps-app-admin-forms-react-hook-form-migration-guide.md
  2. 4 0
      .vscode/settings.json
  3. 12 10
      apps/app/.eslintrc.js
  4. 0 1
      apps/app/bin/openapi/generate-operation-ids/cli.spec.ts
  5. 3 2
      apps/app/bin/print-memory-consumption.ts
  6. 4 2
      apps/app/package.json
  7. 0 1
      apps/app/playwright.config.ts
  8. 50 29
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  9. 12 36
      apps/app/src/client/components/Admin/App/AwsSetting.tsx
  10. 21 43
      apps/app/src/client/components/Admin/App/AzureSetting.tsx
  11. 113 236
      apps/app/src/client/components/Admin/App/FileUploadSetting.tsx
  12. 41 0
      apps/app/src/client/components/Admin/App/FileUploadSetting.types.ts
  13. 14 28
      apps/app/src/client/components/Admin/App/GcsSetting.tsx
  14. 63 19
      apps/app/src/client/components/Admin/App/MailSetting.tsx
  15. 20 7
      apps/app/src/client/components/Admin/App/MaskedInput.tsx
  16. 14 14
      apps/app/src/client/components/Admin/App/SesSetting.tsx
  17. 22 9
      apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx
  18. 13 12
      apps/app/src/client/components/Admin/App/SmtpSetting.tsx
  19. 395 0
      apps/app/src/client/components/Admin/App/useFileUploadSettings.spec.ts
  20. 141 0
      apps/app/src/client/components/Admin/App/useFileUploadSettings.ts
  21. 13 3
      apps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx
  22. 29 13
      apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx
  23. 47 31
      apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx
  24. 47 31
      apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx
  25. 28 15
      apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx
  26. 210 205
      apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx
  27. 27 27
      apps/app/src/client/components/Admin/LegacySlackIntegration/SlackConfiguration.jsx
  28. 17 26
      apps/app/src/client/components/Admin/MarkdownSetting/WhitelistInput.tsx
  29. 39 30
      apps/app/src/client/components/Admin/MarkdownSetting/XssForm.jsx
  30. 38 39
      apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.jsx
  31. 36 44
      apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.jsx
  32. 0 450
      apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.jsx
  33. 446 0
      apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx
  34. 0 260
      apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.jsx
  35. 249 0
      apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx
  36. 0 488
      apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.jsx
  37. 491 0
      apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx
  38. 0 563
      apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.jsx
  39. 552 0
      apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx
  40. 1 1
      apps/app/src/client/components/Admin/Security/SecurityManagementContents.jsx
  41. 0 635
      apps/app/src/client/components/Admin/Security/SecuritySetting.jsx
  42. 58 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/CommentManageRightsSettings.tsx
  43. 75 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/PageAccessRightsSettings.tsx
  44. 289 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/PageDeleteRightsSettings.tsx
  45. 117 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/PageListDisplaySettings.tsx
  46. 36 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/SessionMaxAgeSettings.tsx
  47. 50 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/UserHomepageDeletionSettings.tsx
  48. 88 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx
  49. 65 0
      apps/app/src/client/components/Admin/Security/SecuritySetting/types.ts
  50. 0 235
      apps/app/src/client/services/AdminAppContainer.js
  51. 10 26
      apps/app/src/client/services/AdminImportContainer.js
  52. 3 2
      apps/app/src/client/services/AdminLdapSecurityContainer.js
  53. 4 4
      apps/app/src/client/services/AdminMarkDownContainer.js
  54. 1 1
      apps/app/src/client/services/user-ui-settings.ts
  55. 1 3
      apps/app/src/client/util/apiv3-client.ts
  56. 0 12
      apps/app/src/components/.eslintrc.js
  57. 273 94
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  58. 1 0
      apps/app/src/components/Common/GrowiLogo.jsx
  59. 97 73
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx
  60. 22 10
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  61. 30 26
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx
  62. 8 3
      apps/app/src/components/Common/PagePathNav/Separator.tsx
  63. 36 22
      apps/app/src/components/Common/PagePathNavTitle/PagePathNavTitle.tsx
  64. 1 1
      apps/app/src/components/FontFamily/GlobalFonts.tsx
  65. 25 22
      apps/app/src/components/Layout/AdminLayout.tsx
  66. 109 33
      apps/app/src/components/Layout/BasicLayout.tsx
  67. 13 16
      apps/app/src/components/Layout/NoLoginLayout.tsx
  68. 16 11
      apps/app/src/components/Layout/RawLayout.tsx
  69. 4 5
      apps/app/src/components/Layout/SearchResultLayout.tsx
  70. 23 13
      apps/app/src/components/Layout/ShareLinkLayout.tsx
  71. 4 1
      apps/app/src/components/Navbar/GroundGlassBar.tsx
  72. 195 116
      apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert.tsx
  73. 13 7
      apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert.tsx
  74. 17 7
      apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx
  75. 20 8
      apps/app/src/components/PageView/PageAlerts/PageAlerts.tsx
  76. 9 9
      apps/app/src/components/PageView/PageAlerts/PageGrantAlert.tsx
  77. 15 9
      apps/app/src/components/PageView/PageAlerts/PageRedirectedAlert.tsx
  78. 10 9
      apps/app/src/components/PageView/PageAlerts/PageStaleAlert.tsx
  79. 58 20
      apps/app/src/components/PageView/PageAlerts/TrashPageAlert.tsx
  80. 10 11
      apps/app/src/components/PageView/PageAlerts/WipPageAlert.tsx
  81. 26 14
      apps/app/src/components/PageView/PageContentFooter.tsx
  82. 121 56
      apps/app/src/components/PageView/PageView.tsx
  83. 36 31
      apps/app/src/components/PageView/PageViewLayout.tsx
  84. 23 21
      apps/app/src/components/PageView/RevisionRenderer.tsx
  85. 45 26
      apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx
  86. 37 18
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  87. 3 4
      apps/app/src/components/Script/DrawioViewerScript/DrawioViewerScript.tsx
  88. 14 11
      apps/app/src/components/Script/DrawioViewerScript/use-viewer-min-js-url.spec.ts
  89. 17 14
      apps/app/src/components/ShareLinkPageView/ShareLinkAlert.tsx
  90. 82 48
      apps/app/src/components/ShareLinkPageView/ShareLinkPageView.tsx
  91. 0 4
      apps/app/src/components/User/UserDate.jsx
  92. 17 18
      apps/app/src/components/User/UserInfo.tsx
  93. 7 6
      apps/app/src/components/User/Username.tsx
  94. 4 3
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  95. 0 1
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/KeycloakGroupSyncSettingsForm.tsx
  96. 2 2
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupManagement.tsx
  97. 0 1
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/LdapGroupSyncSettingsForm.tsx
  98. 0 1
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/SyncExecution.tsx
  99. 0 1
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  100. 1 0
      apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts

+ 398 - 0
.serena/memories/apps-app-admin-forms-react-hook-form-migration-guide.md

@@ -0,0 +1,398 @@
+# Admin フォーム - React Hook Form 移行ガイドライン
+
+## プロジェクトコンテキスト
+
+### 現状 (2025年10月時点)
+**✅ PR #10051 完了: Admin フォームの IME 問題は100%解決済み**
+
+全27ファイルが React Hook Form に移行完了し、以下の問題を解決:
+1. ✅ **日本語 IME 入力の問題**: 非制御コンポーネント化により完全解決
+2. ✅ **空値更新の問題**: 完全解決
+3. ⏳ **レガシーライブラリ問題**: Unstated は現在も使用中(次のステップで解決予定)
+
+### 最終目標 (理想像)
+- React Hook Form を利用(✅ 完了)
+- Unstated を完全に廃止(⏳ 次のステップ)
+- グローバルステートは Jotai で管理(⏳ 次のステップ)
+
+### 現在の構成 (中間地点)
+**React Hook Form + Unstated Container のハイブリッド構成**
+
+この構成により:
+1. ✅ IME 入力問題を解決(非制御コンポーネント化)
+2. ✅ 空値更新問題を解決
+3. ✅ Container は残しているが、将来的に Jotai への移行パスを確保
+4. ✅ 段階的な移行によりリグレッションを最小化
+
+## 移行パターン(確立済み)
+
+### 基本的なフォームセットアップ
+
+```typescript
+import { useForm } from 'react-hook-form';
+
+type FormData = {
+  fieldName: string;
+  // ... 他のフィールド
+};
+
+const {
+  register,
+  handleSubmit,
+  reset,
+} = useForm<FormData>();
+```
+
+**重要**: `defaultValues` は指定しない。`useEffect` で `reset()` を呼ぶため不要。
+
+### フォーム値の復元
+
+Container の state とフォームを同期するため、`useEffect` で `reset()` を使用:
+
+```typescript
+useEffect(() => {
+  reset({
+    fieldName: container.state.fieldName || '',
+    // ... 他のフィールド
+  });
+}, [container.state.fieldName, reset]);
+```
+
+### Container を使ったフォーム送信
+
+```typescript
+const onSubmit = useCallback(async(data: FormData) => {
+  try {
+    // 重要: API 呼び出し前に setState の完了を待つ
+    await Promise.all([
+      container.changeField1(data.field1),
+      container.changeField2(data.field2),
+    ]);
+    
+    await container.updateHandler();
+    toastSuccess(t('updated_successfully'));
+  }
+  catch (err) {
+    toastError(err);
+  }
+}, [container, t]);
+
+return (
+  <form onSubmit={handleSubmit(onSubmit)}>
+    {/* フォームフィールド */}
+  </form>
+);
+```
+
+## 重要な注意点
+
+### ⚠️ 1. API 呼び出し前に Container の setState を await する(最重要!)
+
+**問題**: Unstated Container の `setState` は非同期処理です。`change*()` メソッドの後に `await` せずに API ハンドラーを即座に呼ぶと、API リクエストは**古い/古びた値**で送信されます。
+
+❌ **間違い:**
+```typescript
+container.changeSiteUrl(data.siteUrl);
+await container.updateHandler(); // 古い値が送信される!
+```
+
+✅ **正しい:**
+```typescript
+await container.changeSiteUrl(data.siteUrl);
+await container.updateHandler(); // 新しい値が送信される
+```
+
+複数フィールドの場合は `Promise.all()` を使用:
+```typescript
+await Promise.all([
+  container.changeTitle(data.title),
+  container.changeConfidential(data.confidential),
+]);
+await container.updateHandler();
+```
+
+### 2. ラジオボタンの値の型の一致
+
+**問題**: ラジオボタンは**文字列**の値を持ちますが、Container の state は boolean かもしれません。型が一致しないと、選択状態の復元ができません。
+
+❌ **間違い:**
+```typescript
+// HTML: <input type="radio" value="true" />
+reset({
+  isEmailPublished: true, // boolean - 文字列 "true" とマッチしない
+});
+```
+
+✅ **正しい:**
+```typescript
+reset({
+  isEmailPublished: String(container.state.isEmailPublished ?? true),
+});
+```
+
+### 3. チェックボックスの値の扱い
+
+チェックボックスは boolean 値を直接使えます(変換不要):
+```typescript
+reset({
+  fileUpload: container.state.fileUpload ?? false,
+});
+```
+
+### 4. リアルタイム Container 更新に watch() を使わない
+
+**削除したパターン**: フォームの変更を `watch()` と `useEffect` でリアルタイムに Container に同期し戻すのは不要で、複雑さを増すだけです。
+
+❌ **これはやらない:**
+```typescript
+const watchedValues = watch();
+useEffect(() => {
+  container.changeField(watchedValues.field);
+}, [watchedValues]);
+```
+
+✅ **submit 時だけ更新:**
+- Container の state は最終的な API リクエストにのみ使用される
+- `onSubmit` で API ハンドラーを呼ぶ前に更新すればよい
+
+### 5. フォームフィールドの disabled vs readOnly
+
+**問題**: `disabled` フィールドはフォーム送信データから除外されます。
+
+フィールドを編集不可にしたいが、フォームデータには含めたい場合:
+- `disabled` の代わりに `readOnly` を使用
+- または属性を削除して Container/API レイヤーで処理
+
+### 6. defaultValues を指定しない
+
+`useForm()` の引数に `defaultValues` を渡さないこと。
+
+理由:
+- `useEffect` で `reset()` を呼んでいるため、初期値はそちらで設定される
+- コードの重複を避ける
+- 他のファイルとパターンを統一
+
+```typescript
+// ❌ 冗長
+const { register, reset } = useForm({
+  defaultValues: { field: container.state.field }
+});
+useEffect(() => {
+  reset({ field: container.state.field });
+}, [container.state.field]);
+
+// ✅ シンプル
+const { register, reset } = useForm();
+useEffect(() => {
+  reset({ field: container.state.field });
+}, [container.state.field]);
+```
+
+## 高度なパターン
+
+### モジュラーコンポーネント設計(SecuritySetting の例)
+
+大規模なフォームは、複数の小さなコンポーネントに分割することを推奨します。
+
+**親コンポーネント(統合):**
+```typescript
+type FormData = {
+  sessionMaxAge: string;
+  // Container で管理される他のフィールドは不要
+};
+
+const Parent: React.FC<Props> = ({ container }) => {
+  const { register, handleSubmit, reset } = useForm<FormData>();
+
+  useEffect(() => {
+    reset({
+      sessionMaxAge: container.state.sessionMaxAge || '',
+    });
+  }, [reset, container.state.sessionMaxAge]);
+
+  const onSubmit = useCallback(async(data: FormData) => {
+    try {
+      // React Hook Form で管理されているフィールドのみ更新
+      await container.setSessionMaxAge(data.sessionMaxAge);
+      // 全ての設定を保存(Container 管理のフィールドも含む)
+      await container.updateGeneralSecuritySetting();
+      toastSuccess(t('updated'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [container, t]);
+
+  return (
+    <form onSubmit={handleSubmit(onSubmit)}>
+      {/* React Hook Form 管理のフィールド */}
+      <SessionMaxAgeSettings register={register} t={t} />
+      
+      {/* Container 直接管理のフィールド */}
+      <PageListDisplaySettings container={container} t={t} />
+      <PageAccessRightsSettings container={container} t={t} />
+      
+      <button type="submit">{t('Update')}</button>
+    </form>
+  );
+};
+```
+
+**子コンポーネント(React Hook Form 管理):**
+```typescript
+type Props = {
+  register: UseFormRegister<{ sessionMaxAge: string }>;
+  t: (key: string) => string;
+};
+
+export const SessionMaxAgeSettings: React.FC<Props> = ({ register, t }) => {
+  return (
+    <input
+      className="form-control"
+      type="text"
+      {...register('sessionMaxAge')}
+      placeholder="2592000000"
+    />
+  );
+};
+```
+
+**子コンポーネント(Container 直接管理):**
+```typescript
+type Props = {
+  container: AdminGeneralSecurityContainer;
+  t: (key: string) => string;
+};
+
+export const PageListDisplaySettings: React.FC<Props> = ({ container, t }) => {
+  return (
+    <select
+      className="form-control"
+      value={container.state.currentOwnerRestrictionDisplayMode}
+      onChange={(e) => container.changeOwnerRestrictionDisplayMode(e.target.value)}
+    >
+      <option value="Displayed">{t('Displayed')}</option>
+      <option value="Hidden">{t('Hidden')}</option>
+    </select>
+  );
+};
+```
+
+### 統一された Submit ボタン
+
+複数のセクションを持つフォームでも、Submit ボタンは1つに統一:
+- React Hook Form のフィールドは `onSubmit` で処理
+- Container 管理のフィールドは既に state に反映されている
+- 1つの `updateHandler()` で全て保存
+
+## テストチェックリスト
+
+フォーム移行後に必ずテストすること:
+
+1. ✅ **日本語 IME 入力と漢字変換** - 最も重要!
+2. ✅ **ページリロード後にフォームの値が正しく復元される**
+3. ✅ **空値を送信できる**(フィールドをクリアできる)
+4. ✅ **フォーム送信で現在の入力値が送信される**(古い/古びた値ではない)
+5. ✅ **ラジオボタンとチェックボックスが正しく復元される**
+6. ✅ **複数セクションがある場合、全ての設定が1つの Submit で保存される**
+
+## PR #10051 の成果
+
+全27ファイルを React Hook Form に移行完了:
+
+### 主要な成果
+1. **企業認証システム**: LDAP (10フィールド)、OIDC (16フィールド)、SAML (9フィールド)
+2. **SecuritySetting のモジュラー化**: 636行のクラスコンポーネント → 8つの Function Component
+3. **セキュリティ設定**: LocalSecurity (1フィールド)、Import (4フィールド)
+4. **カスタマイズ**: CustomizeCss (1フィールド)、Slack (2フィールド)
+5. **その他**: 17ファイル
+
+### アーキテクチャの改善
+- TypeScript 完全対応
+- PropTypes 廃止
+- Function Component への統一
+- モジュラー設計の採用
+- テスト容易性の向上
+
+## 将来の移行パス: Unstated から Jotai へ
+
+### フェーズ 1: React Hook Form 移行(✅ 完了)
+- 全ての Admin フォームを React Hook Form に移行
+- IME 問題と空値問題を解決
+- 非制御コンポーネントパターンを確立
+
+### フェーズ 2: Jotai 導入準備(次のステップ)
+1. **Container の分析**
+   - どの state が本当にグローバルである必要があるか特定
+   - ローカル state で十分なものを useState に移行
+
+2. **API レイヤーの分離**
+   - Container の `update*Handler()` メソッドを独立した API 関数に抽出
+   - `apps/app/src/client/util/apiv3-client.ts` パターンに従う
+
+3. **段階的な Container の削除**
+   - 小さな Container から始める
+   - Jotai atom で置き換え
+   - 各ステップでテストを実行
+
+### フェーズ 3: 完全な Jotai 移行(最終目標)
+```typescript
+// 理想的な最終形態
+import { atom, useAtom } from 'jotai';
+import { useForm } from 'react-hook-form';
+
+// グローバル state
+const sessionMaxAgeAtom = atom<string>('');
+
+const SecuritySetting = () => {
+  const [sessionMaxAge, setSessionMaxAge] = useAtom(sessionMaxAgeAtom);
+  const { register, handleSubmit, reset } = useForm();
+
+  useEffect(() => {
+    reset({ sessionMaxAge });
+  }, [sessionMaxAge, reset]);
+
+  const onSubmit = async(data: FormData) => {
+    // 直接 API 呼び出し
+    await apiv3Put('/admin/security-settings', {
+      sessionMaxAge: data.sessionMaxAge,
+      // ... 他の設定
+    });
+    
+    // Jotai state を更新
+    setSessionMaxAge(data.sessionMaxAge);
+    toastSuccess('Updated');
+  };
+
+  return <form onSubmit={handleSubmit(onSubmit)}>{/* ... */}</form>;
+};
+```
+
+## 適用可能な範囲
+
+このガイドラインは以下の Admin フォームに適用可能:
+
+- Unstated Container でグローバルステートを管理しているフォーム
+- `apps/app/src/client/services/Admin*Container.js` 配下の Container を使用しているフォーム
+- `/admin` ルート配下のコンポーネント
+- 将来的に Jotai に移行予定のフォーム
+
+## 関連ファイル
+
+### 現在使用中
+- Container 群: `apps/app/src/client/services/Admin*Container.js`
+- ボタンコンポーネント: `apps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx`
+- React Hook Form: v7.45.4
+
+### 将来導入予定
+- Jotai: グローバル state 管理
+- SWR または React Query: サーバー state 管理(検討中)
+
+## 参考実装
+
+以下のファイルがベストプラクティスの参考になります:
+
+1. **モジュラー構造**: `apps/app/src/client/components/Admin/Security/SecuritySetting/`
+2. **React Hook Form 基本**: `apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx`
+3. **複雑なフォーム**: `apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx`
+4. **既存の良い実装**: `apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx`

+ 4 - 0
.vscode/settings.json

@@ -11,6 +11,10 @@
     "editor.defaultFormatter": "biomejs.biome"
   },
 
+  "[json]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   // use vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   "stylelint.validate": ["css", "less", "scss"],

+ 12 - 10
apps/app/.eslintrc.js

@@ -2,12 +2,8 @@
  * @type {import('eslint').Linter.Config}
  */
 module.exports = {
-  extends: [
-    'next/core-web-vitals',
-    'weseek/react',
-  ],
-  plugins: [
-  ],
+  extends: ['next/core-web-vitals', 'weseek/react'],
+  plugins: [],
   ignorePatterns: [
     'dist/**',
     '**/dist/**',
@@ -45,11 +41,21 @@ module.exports = {
     'src/features/page-bulk-export/**',
     'src/features/growi-plugin/**',
     'src/features/opentelemetry/**',
+    'src/features/openai/**',
     'src/features/rate-limiter/**',
     'src/stores-universal/**',
     'src/interfaces/**',
     'src/utils/**',
+    'src/components/**',
     'src/services/**',
+    'src/stores/**',
+    'src/pages/**',
+    'src/server/crowi/**',
+    'src/server/events/**',
+    'src/server/interfaces/**',
+    'src/server/util/**',
+    'src/server/app.ts',
+    'src/server/repl.ts',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
@@ -58,10 +64,6 @@ module.exports = {
     },
   },
   rules: {
-    'no-restricted-imports': ['error', {
-      name: 'axios',
-      message: 'Please use src/utils/axios instead.',
-    }],
     '@typescript-eslint/no-var-requires': 'off',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

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

@@ -1,5 +1,4 @@
 import { writeFileSync } from 'node:fs';
-
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 
 import { generateOperationIds } from './generate-operation-ids';

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

@@ -12,7 +12,6 @@
  */
 
 import { get } from 'node:http';
-
 import WebSocket from 'ws';
 
 interface MemoryInfo {
@@ -297,7 +296,9 @@ class NodeMemoryConsumptionChecker {
     // Memory Flags
     if (info.memoryFlags.length > 0) {
       console.log('\n🔸 Memory Flags:');
-      info.memoryFlags.forEach((flag) => console.log(`  ${flag}`));
+      info.memoryFlags.forEach((flag) => {
+        console.log(`  ${flag}`);
+      });
     }
 
     // Summary

+ 4 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.3.3",
+  "version": "7.3.4-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -246,7 +246,7 @@
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "uuid": "^11.0.3",
-    "validator": "^13.7.0",
+    "validator": "^13.15.20",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
@@ -288,6 +288,7 @@
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
+    "@types/supertest": "^6.0.3",
     "@types/testing-library__dom": "^7.5.0",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unist": "^3.0.3",
@@ -337,6 +338,7 @@
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "source-map-loader": "^4.0.1",
+    "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
     "unist-util-is": "^6.0.0",
     "unist-util-visit-parents": "^6.0.0"

+ 0 - 1
apps/app/playwright.config.ts

@@ -1,6 +1,5 @@
 import fs from 'node:fs';
 import path from 'node:path';
-
 import { defineConfig, devices, type Project } from '@playwright/test';
 
 const authFile = path.resolve(__dirname, './playwright/.auth/admin.json');

+ 50 - 29
apps/app/src/client/components/Admin/App/AppSetting.jsx

@@ -1,7 +1,8 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { useTranslation, i18n } from 'next-i18next';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
@@ -20,8 +21,44 @@ const AppSetting = (props) => {
   const { adminAppContainer } = props;
   const { t } = useTranslation(['admin', 'commons']);
 
-  const submitHandler = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Reset form when adminAppContainer state changes (e.g., after reload)
+  useEffect(() => {
+    reset({
+      title: adminAppContainer.state.title || '',
+      confidential: adminAppContainer.state.confidential || '',
+      globalLang: adminAppContainer.state.globalLang || 'en-US',
+      // Convert boolean to string for radio button value
+      isEmailPublishedForNewUser: String(adminAppContainer.state.isEmailPublishedForNewUser ?? true),
+      fileUpload: adminAppContainer.state.fileUpload ?? false,
+    });
+  }, [
+    adminAppContainer.state.title,
+    adminAppContainer.state.confidential,
+    adminAppContainer.state.globalLang,
+    adminAppContainer.state.isEmailPublishedForNewUser,
+    adminAppContainer.state.fileUpload,
+    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.changeFileUpload(data.fileUpload);
+
       await adminAppContainer.updateAppSettingHandler();
       toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
     }
@@ -33,18 +70,15 @@ const AppSetting = (props) => {
 
 
   return (
-    <React.Fragment>
+    <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>
         <div className="col-md-6">
           <input
             className="form-control"
             type="text"
-            value={adminAppContainer.state.title || ''}
-            onChange={(e) => {
-              adminAppContainer.changeTitle(e.target.value);
-            }}
             placeholder="GROWI"
+            {...register('title')}
           />
           <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
         </div>
@@ -60,11 +94,8 @@ const AppSetting = (props) => {
           <input
             className="form-control"
             type="text"
-            value={adminAppContainer.state.confidential || ''}
-            onChange={(e) => {
-              adminAppContainer.changeConfidential(e.target.value);
-            }}
             placeholder={t('admin:app_setting.confidential_example')}
+            {...register('confidential')}
           />
           <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
         </div>
@@ -88,12 +119,8 @@ const AppSetting = (props) => {
                     type="radio"
                     id={`radioLang${locale}`}
                     className="form-check-input"
-                    name="globalLang"
                     value={locale}
-                    checked={adminAppContainer.state.globalLang === locale}
-                    onChange={(e) => {
-                      adminAppContainer.changeGlobalLang(e.target.value);
-                    }}
+                    {...register('globalLang')}
                   />
                   <label className="form-label form-check-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name')}</label>
                 </div>
@@ -116,9 +143,8 @@ const AppSetting = (props) => {
               type="radio"
               id="radio-email-show"
               className="form-check-input"
-              name="mailVisibility"
-              checked={adminAppContainer.state.isEmailPublishedForNewUser === true}
-              onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(true) }}
+              value="true"
+              {...register('isEmailPublishedForNewUser')}
             />
             <label className="form-label form-check-label" htmlFor="radio-email-show">{t('commons:Show')}</label>
           </div>
@@ -128,9 +154,8 @@ const AppSetting = (props) => {
               type="radio"
               id="radio-email-hide"
               className="form-check-input"
-              name="mailVisibility"
-              checked={adminAppContainer.state.isEmailPublishedForNewUser === false}
-              onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(false) }}
+              value="false"
+              {...register('isEmailPublishedForNewUser')}
             />
             <label className="form-label form-check-label" htmlFor="radio-email-hide">{t('commons:Hide')}</label>
           </div>
@@ -150,11 +175,7 @@ const AppSetting = (props) => {
               type="checkbox"
               id="cbFileUpload"
               className="form-check-input"
-              name="fileUpload"
-              checked={adminAppContainer.state.fileUpload}
-              onChange={(e) => {
-                adminAppContainer.changeFileUpload(e.target.checked);
-              }}
+              {...register('fileUpload')}
             />
             <label
               className="form-label form-check-label"
@@ -170,8 +191,8 @@ const AppSetting = (props) => {
         </div>
       </div>
 
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-    </React.Fragment>
+      <AdminUpdateButtonRow type="submit" disabled={adminAppContainer.state.retrieveError != null} />
+    </form>
   );
 
 };

+ 12 - 36
apps/app/src/client/components/Admin/App/AwsSetting.tsx

@@ -1,21 +1,14 @@
 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 = {
-  s3ReferenceFileWithRelayMode
-  s3Region
-  s3CustomEndpoint
-  s3Bucket
-  s3AccessKeyId
-  s3SecretAccessKey
+  register: UseFormRegister<FileUploadFormValues>
+  s3ReferenceFileWithRelayMode: boolean
   onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeS3Region: (val: string) => void
-  onChangeS3CustomEndpoint: (val: string) => void
-  onChangeS3Bucket: (val: string) => void
-  onChangeS3AccessKeyId: (val: string) => void
-  onChangeS3SecretAccessKey: (val: string) => void
 };
 
 export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => {
@@ -23,7 +16,6 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
 
   return (
     <>
-
       <div className="row my-3">
         <label className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
@@ -46,16 +38,16 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
               <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')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
             </div>
 
@@ -76,10 +68,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
             className="form-control"
             placeholder={`${t('eg')} ap-northeast-1`}
-            value={props.s3Region || ''}
-            onChange={(e) => {
-              props?.onChangeS3Region(e.target.value);
-            }}
+            {...props.register('s3Region')}
           />
         </div>
       </div>
@@ -93,10 +82,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             type="text"
             placeholder={`${t('eg')} http://localhost:9000`}
-            value={props.s3CustomEndpoint || ''}
-            onChange={(e) => {
-              props?.onChangeS3CustomEndpoint(e.target.value);
-            }}
+            {...props.register('s3CustomEndpoint')}
           />
           <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
         </div>
@@ -111,10 +97,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             type="text"
             placeholder={`${t('eg')} crowi`}
-            value={props.s3Bucket || ''}
-            onChange={(e) => {
-              props.onChangeS3Bucket(e.target.value);
-            }}
+            {...props.register('s3Bucket')}
           />
         </div>
       </div>
@@ -127,10 +110,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
             className="form-control"
             type="text"
-            value={props.s3AccessKeyId || ''}
-            onChange={(e) => {
-              props?.onChangeS3AccessKeyId(e.target.value);
-            }}
+            {...props.register('s3AccessKeyId')}
           />
         </div>
       </div>
@@ -143,15 +123,11 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
             className="form-control"
             type="text"
-            onChange={(e) => {
-              props?.onChangeS3SecretAccessKey(e.target.value);
-            }}
+            {...props.register('s3SecretAccessKey')}
           />
           <p className="form-text text-muted">{t('admin:app_setting.s3_secret_access_key_input_description')}</p>
         </div>
       </div>
-
-
     </>
   );
 };

+ 21 - 43
apps/app/src/client/components/Admin/App/AzureSetting.tsx

@@ -1,29 +1,21 @@
 import type { JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 import MaskedInput from './MaskedInput';
 
-
 export type AzureSettingMoleculeProps = {
-  azureReferenceFileWithRelayMode
-  azureUseOnlyEnvVars
-  azureTenantId
-  azureClientId
-  azureClientSecret
-  azureStorageAccountName
-  azureStorageContainerName
-  envAzureTenantId?
-  envAzureClientId?
-  envAzureClientSecret?
-  envAzureStorageAccountName?
-  envAzureStorageContainerName?
+  register: UseFormRegister<FileUploadFormValues>
+  azureReferenceFileWithRelayMode: boolean
+  azureUseOnlyEnvVars: boolean
+  envAzureTenantId?: string
+  envAzureClientId?: string
+  envAzureClientSecret?: string
+  envAzureStorageAccountName?: string
+  envAzureStorageContainerName?: string
   onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeAzureTenantId: (val: string) => void
-  onChangeAzureClientId: (val: string) => void
-  onChangeAzureClientSecret: (val: string) => void
-  onChangeAzureStorageAccountName: (val: string) => void
-  onChangeAzureStorageContainerName: (val: string) => void
 };
 
 export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
@@ -32,21 +24,15 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
   const {
     azureReferenceFileWithRelayMode,
     azureUseOnlyEnvVars,
-    azureTenantId,
-    azureClientId,
-    azureClientSecret,
-    azureStorageAccountName,
     envAzureTenantId,
     envAzureClientId,
     envAzureClientSecret,
     envAzureStorageAccountName,
-    azureStorageContainerName,
     envAzureStorageContainerName,
   } = props;
 
   return (
     <>
-
       <div className="row form-group my-3">
         <label className="text-left text-md-right col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
@@ -69,16 +55,16 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <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')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
             </div>
 
@@ -116,10 +102,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_tenant_id')}</th>
             <td>
               <MaskedInput
-                name="azureTenantId"
+                register={props.register}
+                fieldName="azureTenantId"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureTenantId}
-                onChange={e => props?.onChangeAzureTenantId(e.target.value)}
               />
             </td>
             <td>
@@ -134,10 +119,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_client_id')}</th>
             <td>
               <MaskedInput
-                name="azureClientId"
+                register={props.register}
+                fieldName="azureClientId"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureClientId}
-                onChange={e => props?.onChangeAzureClientId(e.target.value)}
               />
             </td>
             <td>
@@ -152,10 +136,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_client_secret')}</th>
             <td>
               <MaskedInput
-                name="azureClientSecret"
+                register={props.register}
+                fieldName="azureClientSecret"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureClientSecret}
-                onChange={e => props?.onChangeAzureClientSecret(e.target.value)}
               />
             </td>
             <td>
@@ -172,10 +155,8 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <input
                 className="form-control"
                 type="text"
-                name="azureStorageAccountName"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureStorageAccountName}
-                onChange={e => props?.onChangeAzureStorageAccountName(e.target.value)}
+                {...props.register('azureStorageAccountName')}
               />
             </td>
             <td>
@@ -192,10 +173,8 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <input
                 className="form-control"
                 type="text"
-                name="azureStorageContainerName"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureStorageContainerName}
-                onChange={e => props?.onChangeAzureStorageContainerName(e.target.value)}
+                {...props.register('azureStorageContainerName')}
               />
             </td>
             <td>
@@ -208,7 +187,6 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
           </tr>
         </tbody>
       </table>
-
     </>
   );
 };

+ 113 - 236
apps/app/src/client/components/Admin/App/FileUploadSetting.tsx

@@ -1,34 +1,94 @@
-import type { ChangeEvent, JSX } from 'react';
-import React, { useCallback } from 'react';
+import type { JSX } from 'react';
+import { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useForm, useController } from 'react-hook-form';
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { FileUploadType } from '~/interfaces/file-uploader';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 import { AwsSettingMolecule } from './AwsSetting';
-import type { AwsSettingMoleculeProps } from './AwsSetting';
 import { AzureSettingMolecule } from './AzureSetting';
-import type { AzureSettingMoleculeProps } from './AzureSetting';
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 import { GcsSettingMolecule } from './GcsSetting';
-import type { GcsSettingMoleculeProps } from './GcsSetting';
+import { useFileUploadSettings } from './useFileUploadSettings';
 
-type FileUploadSettingMoleculeProps = {
-  fileUploadType: string
-  isFixedFileUploadByEnvVar: boolean
-  envFileUploadType?: string
-  onChangeFileUploadType: (e: ChangeEvent, type: string) => void
-} & AwsSettingMoleculeProps & GcsSettingMoleculeProps & AzureSettingMoleculeProps;
-
-export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
+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,
+  });
+
+  // Use controller for fileUploadType radio buttons
+  const { field: fileUploadTypeField } = useController({
+    name: 'fileUploadType',
+    control,
+  });
+
+  // Use controller for relay mode fields
+  const { field: s3RelayModeField } = useController({
+    name: 's3ReferenceFileWithRelayMode',
+    control,
+  });
+
+  const { field: gcsRelayModeField } = useController({
+    name: 'gcsReferenceFileWithRelayMode',
+    control,
+  });
+
+  const { field: azureRelayModeField } = useController({
+    name: 'azureReferenceFileWithRelayMode',
+    control,
+  });
+
+  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]);
+
+  if (isLoading) {
+    return <div>Loading...</div>;
+  }
+
+  if (error || !data) {
+    return <div>Error loading settings</div>;
+  }
 
   return (
-    <>
+    <form onSubmit={handleSubmit(onSubmit)}>
       <p className="card custom-card bg-warning-subtle my-3">
         {t('admin:app_setting.file_upload')}
         <span className="text-danger mt-1">
@@ -51,24 +111,27 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
                   className="form-check-input"
                   name="file-upload-type"
                   id={`file-upload-type-radio-${type}`}
-                  checked={props.fileUploadType === type}
-                  disabled={props.isFixedFileUploadByEnvVar}
-                  onChange={(e) => { props?.onChangeFileUploadType(e, type) }}
+                  checked={fileUploadTypeField.value === type}
+                  disabled={data.isFixedFileUploadByEnvVar}
+                  onChange={() => fileUploadTypeField.onChange(type)}
                 />
-                <label className="form-label form-check-label" htmlFor={`file-upload-type-radio-${type}`}>{t(`admin:app_setting.${type}_label`)}</label>
+                <label className="form-label form-check-label" htmlFor={`file-upload-type-radio-${type}`}>
+                  {t(`admin:app_setting.${type}_label`)}
+                </label>
               </div>
             );
           })}
         </div>
-        {props.isFixedFileUploadByEnvVar && (
+        {data.isFixedFileUploadByEnvVar && (
           <p className="alert alert-warning mt-2 text-start offset-3 col-6">
             <span className="material-symbols-outlined">help</span>
-            <b>FIXED</b><br />
+            <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: props.envFileUploadType,
+                envVar: data.envFileUploadType,
               }),
             }}
             />
@@ -76,229 +139,43 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
         )}
       </div>
 
-      {props.fileUploadType === 'aws' && (
+      {fileUploadType === 'aws' && (
         <AwsSettingMolecule
-          s3ReferenceFileWithRelayMode={props.s3ReferenceFileWithRelayMode}
-          s3Region={props.s3Region}
-          s3CustomEndpoint={props.s3CustomEndpoint}
-          s3Bucket={props.s3Bucket}
-          s3AccessKeyId={props.s3AccessKeyId}
-          s3SecretAccessKey={props.s3SecretAccessKey}
-          onChangeS3ReferenceFileWithRelayMode={props.onChangeS3ReferenceFileWithRelayMode}
-          onChangeS3Region={props.onChangeS3Region}
-          onChangeS3CustomEndpoint={props.onChangeS3CustomEndpoint}
-          onChangeS3Bucket={props.onChangeS3Bucket}
-          onChangeS3AccessKeyId={props.onChangeS3AccessKeyId}
-          onChangeS3SecretAccessKey={props.onChangeS3SecretAccessKey}
+          register={register}
+          s3ReferenceFileWithRelayMode={s3RelayModeField.value}
+          onChangeS3ReferenceFileWithRelayMode={s3RelayModeField.onChange}
         />
       )}
-      {props.fileUploadType === 'gcs' && (
+
+      {fileUploadType === 'gcs' && (
         <GcsSettingMolecule
-          gcsReferenceFileWithRelayMode={props.gcsReferenceFileWithRelayMode}
-          gcsUseOnlyEnvVars={props.gcsUseOnlyEnvVars}
-          gcsApiKeyJsonPath={props.gcsApiKeyJsonPath}
-          gcsBucket={props.gcsBucket}
-          gcsUploadNamespace={props.gcsUploadNamespace}
-          envGcsApiKeyJsonPath={props.envGcsApiKeyJsonPath}
-          envGcsBucket={props.envGcsBucket}
-          envGcsUploadNamespace={props.envGcsUploadNamespace}
-          onChangeGcsReferenceFileWithRelayMode={props.onChangeGcsReferenceFileWithRelayMode}
-          onChangeGcsApiKeyJsonPath={props.onChangeGcsApiKeyJsonPath}
-          onChangeGcsBucket={props.onChangeGcsBucket}
-          onChangeGcsUploadNamespace={props.onChangeGcsUploadNamespace}
+          register={register}
+          gcsReferenceFileWithRelayMode={gcsRelayModeField.value}
+          gcsUseOnlyEnvVars={data.gcsUseOnlyEnvVars}
+          envGcsApiKeyJsonPath={data.envGcsApiKeyJsonPath}
+          envGcsBucket={data.envGcsBucket}
+          envGcsUploadNamespace={data.envGcsUploadNamespace}
+          onChangeGcsReferenceFileWithRelayMode={gcsRelayModeField.onChange}
         />
       )}
-      {props.fileUploadType === 'azure' && (
+
+      {fileUploadType === 'azure' && (
         <AzureSettingMolecule
-          azureReferenceFileWithRelayMode={props.azureReferenceFileWithRelayMode}
-          azureUseOnlyEnvVars={props.azureUseOnlyEnvVars}
-          azureTenantId={props.azureTenantId}
-          azureClientId={props.azureClientId}
-          azureClientSecret={props.azureClientSecret}
-          azureStorageAccountName={props.azureStorageAccountName}
-          azureStorageContainerName={props.azureStorageContainerName}
-          envAzureStorageAccountName={props.envAzureStorageAccountName}
-          envAzureStorageContainerName={props.envAzureStorageContainerName}
-          envAzureTenantId={props.envAzureTenantId}
-          envAzureClientId={props.envAzureClientId}
-          envAzureClientSecret={props.envAzureClientSecret}
-          onChangeAzureReferenceFileWithRelayMode={props.onChangeAzureReferenceFileWithRelayMode}
-          onChangeAzureTenantId={props.onChangeAzureTenantId}
-          onChangeAzureClientId={props.onChangeAzureClientId}
-          onChangeAzureClientSecret={props.onChangeAzureClientSecret}
-          onChangeAzureStorageAccountName={props.onChangeAzureStorageAccountName}
-          onChangeAzureStorageContainerName={props.onChangeAzureStorageContainerName}
+          register={register}
+          azureReferenceFileWithRelayMode={azureRelayModeField.value}
+          azureUseOnlyEnvVars={data.azureUseOnlyEnvVars}
+          envAzureTenantId={data.envAzureTenantId}
+          envAzureClientId={data.envAzureClientId}
+          envAzureClientSecret={data.envAzureClientSecret}
+          envAzureStorageAccountName={data.envAzureStorageAccountName}
+          envAzureStorageContainerName={data.envAzureStorageContainerName}
+          onChangeAzureReferenceFileWithRelayMode={azureRelayModeField.onChange}
         />
       )}
-    </>
-  );
-});
-FileUploadSettingMolecule.displayName = 'FileUploadSettingMolecule';
-
-
-type FileUploadSettingProps = {
-  adminAppContainer: AdminAppContainer
-}
-
-const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
-  const { t } = useTranslation(['admin', 'commons']);
-  const { adminAppContainer } = props;
-
-  const {
-    fileUploadType, isFixedFileUploadByEnvVar, envFileUploadType, retrieveError,
-    s3ReferenceFileWithRelayMode,
-    s3Region, s3CustomEndpoint, s3Bucket,
-    s3AccessKeyId, s3SecretAccessKey,
-    gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars,
-    gcsApiKeyJsonPath, envGcsApiKeyJsonPath, gcsBucket,
-    envGcsBucket, gcsUploadNamespace, envGcsUploadNamespace,
-    azureReferenceFileWithRelayMode, azureUseOnlyEnvVars,
-    azureTenantId, azureClientId, azureClientSecret,
-    azureStorageAccountName, azureStorageContainerName,
-    envAzureTenantId, envAzureClientId, envAzureClientSecret,
-    envAzureStorageAccountName, envAzureStorageContainerName,
-  } = adminAppContainer.state;
-
-  const submitHandler = useCallback(async() => {
-    try {
-      await adminAppContainer.updateFileUploadSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminAppContainer, t]);
-
-  const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {
-    adminAppContainer.changeFileUploadType(type);
-  }, [adminAppContainer]);
-
-  // S3
-  const onChangeS3ReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
-    adminAppContainer.changeS3ReferenceFileWithRelayMode(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3RegionHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3Region(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3CustomEndpointHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3CustomEndpoint(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3BucketHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3Bucket(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3AccessKeyIdHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3AccessKeyId(val);
-  }, [adminAppContainer]);
-
-  const onChangeS3SecretAccessKeyHandler = useCallback((val: string) => {
-    adminAppContainer.changeS3SecretAccessKey(val);
-  }, [adminAppContainer]);
-
-  // GCS
-  const onChangeGcsReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
-    adminAppContainer.changeGcsReferenceFileWithRelayMode(val);
-  }, [adminAppContainer]);
 
-  const onChangeGcsApiKeyJsonPathHandler = useCallback((val: string) => {
-    adminAppContainer.changeGcsApiKeyJsonPath(val);
-  }, [adminAppContainer]);
-
-  const onChangeGcsBucketHandler = useCallback((val: string) => {
-    adminAppContainer.changeGcsBucket(val);
-  }, [adminAppContainer]);
-
-  const onChangeGcsUploadNamespaceHandler = useCallback((val: string) => {
-    adminAppContainer.changeGcsUploadNamespace(val);
-  }, [adminAppContainer]);
-
-  // Azure
-  const onChangeAzureReferenceFileWithRelayModeHandler = useCallback((val: boolean) => {
-    adminAppContainer.changeAzureReferenceFileWithRelayMode(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureTenantIdHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureTenantId(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureClientIdHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureClientId(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureClientSecretHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureClientSecret(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureStorageAccountNameHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureStorageAccountName(val);
-  }, [adminAppContainer]);
-
-  const onChangeAzureStorageContainerNameHandler = useCallback((val: string) => {
-    adminAppContainer.changeAzureStorageContainerName(val);
-  }, [adminAppContainer]);
-
-  return (
-    <>
-      <FileUploadSettingMolecule
-        fileUploadType={fileUploadType}
-        isFixedFileUploadByEnvVar={isFixedFileUploadByEnvVar}
-        envFileUploadType={envFileUploadType}
-        onChangeFileUploadType={onChangeFileUploadTypeHandler}
-        s3ReferenceFileWithRelayMode={s3ReferenceFileWithRelayMode}
-        s3Region={s3Region}
-        s3CustomEndpoint={s3CustomEndpoint}
-        s3Bucket={s3Bucket}
-        s3AccessKeyId={s3AccessKeyId}
-        s3SecretAccessKey={s3SecretAccessKey}
-        onChangeS3ReferenceFileWithRelayMode={onChangeS3ReferenceFileWithRelayModeHandler}
-        onChangeS3Region={onChangeS3RegionHandler}
-        onChangeS3CustomEndpoint={onChangeS3CustomEndpointHandler}
-        onChangeS3Bucket={onChangeS3BucketHandler}
-        onChangeS3AccessKeyId={onChangeS3AccessKeyIdHandler}
-        onChangeS3SecretAccessKey={onChangeS3SecretAccessKeyHandler}
-        gcsReferenceFileWithRelayMode={gcsReferenceFileWithRelayMode}
-        gcsUseOnlyEnvVars={gcsUseOnlyEnvVars}
-        gcsApiKeyJsonPath={gcsApiKeyJsonPath}
-        gcsBucket={gcsBucket}
-        gcsUploadNamespace={gcsUploadNamespace}
-        envGcsApiKeyJsonPath={envGcsApiKeyJsonPath}
-        envGcsBucket={envGcsBucket}
-        envGcsUploadNamespace={envGcsUploadNamespace}
-        onChangeGcsReferenceFileWithRelayMode={onChangeGcsReferenceFileWithRelayModeHandler}
-        onChangeGcsApiKeyJsonPath={onChangeGcsApiKeyJsonPathHandler}
-        onChangeGcsBucket={onChangeGcsBucketHandler}
-        onChangeGcsUploadNamespace={onChangeGcsUploadNamespaceHandler}
-        azureReferenceFileWithRelayMode={azureReferenceFileWithRelayMode}
-        azureUseOnlyEnvVars={azureUseOnlyEnvVars}
-        azureTenantId={azureTenantId}
-        azureClientId={azureClientId}
-        azureClientSecret={azureClientSecret}
-        azureStorageAccountName={azureStorageAccountName}
-        azureStorageContainerName={azureStorageContainerName}
-        envAzureTenantId={envAzureTenantId}
-        envAzureClientId={envAzureClientId}
-        envAzureClientSecret={envAzureClientSecret}
-        envAzureStorageAccountName={envAzureStorageAccountName}
-        envAzureStorageContainerName={envAzureStorageContainerName}
-        onChangeAzureReferenceFileWithRelayMode={onChangeAzureReferenceFileWithRelayModeHandler}
-        onChangeAzureTenantId={onChangeAzureTenantIdHandler}
-        onChangeAzureClientId={onChangeAzureClientIdHandler}
-        onChangeAzureClientSecret={onChangeAzureClientSecretHandler}
-        onChangeAzureStorageAccountName={onChangeAzureStorageAccountNameHandler}
-        onChangeAzureStorageContainerName={onChangeAzureStorageContainerNameHandler}
-      />
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
-    </>
+      <AdminUpdateButtonRow type="submit" disabled={isLoading} />
+    </form>
   );
 };
 
-
-/**
- * Wrapper component for using unstated
- */
-const FileUploadSettingWrapper = withUnstatedContainers(FileUploadSetting, [AdminAppContainer]);
-
-export default FileUploadSettingWrapper;
+export default FileUploadSetting;

+ 41 - 0
apps/app/src/client/components/Admin/App/FileUploadSetting.types.ts

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

+ 14 - 28
apps/app/src/client/components/Admin/App/GcsSetting.tsx

@@ -1,21 +1,18 @@
 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 = {
-  gcsReferenceFileWithRelayMode
-  gcsUseOnlyEnvVars
-  gcsApiKeyJsonPath
-  gcsBucket
-  gcsUploadNamespace
-  envGcsApiKeyJsonPath?
-  envGcsBucket?
-  envGcsUploadNamespace?
+  register: UseFormRegister<FileUploadFormValues>
+  gcsReferenceFileWithRelayMode: boolean
+  gcsUseOnlyEnvVars: boolean
+  envGcsApiKeyJsonPath?: string
+  envGcsBucket?: string
+  envGcsUploadNamespace?: string
   onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeGcsApiKeyJsonPath: (val: string) => void
-  onChangeGcsBucket: (val: string) => void
-  onChangeGcsUploadNamespace: (val: string) => void
 };
 
 export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
@@ -24,17 +21,13 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
   const {
     gcsReferenceFileWithRelayMode,
     gcsUseOnlyEnvVars,
-    gcsApiKeyJsonPath,
     envGcsApiKeyJsonPath,
-    gcsBucket,
     envGcsBucket,
-    gcsUploadNamespace,
     envGcsUploadNamespace,
   } = props;
 
   return (
     <>
-
       <div className="row my-3">
         <label className="text-start text-md-end col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
@@ -57,16 +50,16 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <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')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
             </div>
 
@@ -106,10 +99,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
                 className="form-control"
                 type="text"
-                name="gcsApiKeyJsonPath"
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsApiKeyJsonPath}
-                onChange={e => props?.onChangeGcsApiKeyJsonPath(e.target.value)}
+                {...props.register('gcsApiKeyJsonPath')}
               />
             </td>
             <td>
@@ -126,10 +117,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
                 className="form-control"
                 type="text"
-                name="gcsBucket"
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsBucket}
-                onChange={e => props?.onChangeGcsBucket(e.target.value)}
+                {...props.register('gcsBucket')}
               />
             </td>
             <td>
@@ -146,10 +135,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
                 className="form-control"
                 type="text"
-                name="gcsUploadNamespace"
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsUploadNamespace}
-                onChange={e => props?.onChangeGcsUploadNamespace(e.target.value)}
+                {...props.register('gcsUploadNamespace')}
               />
             </td>
             <td>
@@ -162,7 +149,6 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
           </tr>
         </tbody>
       </table>
-
     </>
   );
 };

+ 63 - 19
apps/app/src/client/components/Admin/App/MailSetting.tsx

@@ -1,14 +1,15 @@
-import React from 'react';
+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 { withUnstatedContainers } from '../../UnstatedUtils';
 
-import SesSetting from './SesSetting';
-import SmtpSetting from './SmtpSetting';
+import { SesSetting } from './SesSetting';
+import { SmtpSetting } from './SmtpSetting';
 
 
 type Props = {
@@ -22,15 +23,61 @@ const MailSetting = (props: Props) => {
 
   const transmissionMethods = ['smtp', 'ses'];
 
-  async function submitHandler() {
+  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');
+
+  // Reset form when adminAppContainer state changes
+  useEffect(() => {
+    reset({
+      fromAddress: adminAppContainer.state.fromAddress || '',
+      transmissionMethod: adminAppContainer.state.transmissionMethod || 'smtp',
+      smtpHost: adminAppContainer.state.smtpHost || '',
+      smtpPort: adminAppContainer.state.smtpPort || '',
+      smtpUser: adminAppContainer.state.smtpUser || '',
+      smtpPassword: adminAppContainer.state.smtpPassword || '',
+      sesAccessKeyId: adminAppContainer.state.sesAccessKeyId || '',
+      sesSecretAccessKey: adminAppContainer.state.sesSecretAccessKey || '',
+    });
+  }, [
+    adminAppContainer.state.fromAddress,
+    adminAppContainer.state.transmissionMethod,
+    adminAppContainer.state.smtpHost,
+    adminAppContainer.state.smtpPort,
+    adminAppContainer.state.smtpUser,
+    adminAppContainer.state.smtpPassword,
+    adminAppContainer.state.sesAccessKeyId,
+    adminAppContainer.state.sesSecretAccessKey,
+    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]);
 
   async function sendTestEmailHandler() {
     const { adminAppContainer } = props;
@@ -45,19 +92,18 @@ const MailSetting = (props: Props) => {
 
 
   return (
-    <React.Fragment>
+    <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="row mb-5">
+      <div className="row mb-4">
         <label className="col-md-3 col-form-label text-end">{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`}
-            value={adminAppContainer.state.fromAddress || ''}
-            onChange={(e) => { adminAppContainer.changeFromAddress(e.target.value) }}
+            {...register('fromAddress')}
           />
         </div>
       </div>
@@ -73,12 +119,9 @@ const MailSetting = (props: Props) => {
                 <input
                   type="radio"
                   className="form-check-input"
-                  name="transmission-method"
                   id={`transmission-method-radio-${method}`}
-                  checked={adminAppContainer.state.transmissionMethod === method}
-                  onChange={(e) => {
-                    adminAppContainer.changeTransmissionMethod(method);
-                  }}
+                  value={method}
+                  {...register('transmissionMethod')}
                 />
                 <label className="form-label form-check-label" htmlFor={`transmission-method-radio-${method}`}>{t(`admin:app_setting.${method}_label`)}</label>
               </div>
@@ -87,12 +130,13 @@ const MailSetting = (props: Props) => {
         </div>
       </div>
 
-      {adminAppContainer.state.transmissionMethod === 'smtp' && <SmtpSetting />}
-      {adminAppContainer.state.transmissionMethod === 'ses' && <SesSetting />}
+      {currentTransmissionMethod === 'smtp' && <SmtpSetting register={register} />}
+      {currentTransmissionMethod === 'ses' && <SesSetting register={register} />}
 
       <div className="row my-3">
-        <div className="mx-auto">
-          <button type="button" className="btn btn-primary" onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null}>
+        <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>
           {adminAppContainer.state.transmissionMethod === 'smtp' && (
@@ -102,7 +146,7 @@ const MailSetting = (props: Props) => {
           )}
         </div>
       </div>
-    </React.Fragment>
+    </form>
   );
 
 };

+ 20 - 7
apps/app/src/client/components/Admin/App/MaskedInput.tsx

@@ -1,13 +1,19 @@
+import type { ChangeEvent } from 'react';
 import { useState, type JSX } from 'react';
 
+import type { UseFormRegister } from 'react-hook-form';
+
 import styles from './MaskedInput.module.scss';
 
 type Props = {
-  name: string
+  name?: string
   readOnly: boolean
-  value: string
-  onChange?: (e: any) => void
+  value?: string
+  onChange?: (e: ChangeEvent<HTMLInputElement>) => void
   tabIndex?: number | undefined
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  register?: UseFormRegister<any>
+  fieldName?: string
 };
 
 export default function MaskedInput(props: Props): JSX.Element {
@@ -17,19 +23,26 @@ export default function MaskedInput(props: Props): JSX.Element {
   };
 
   const {
-    name, readOnly, value, onChange, tabIndex,
+    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,
+    };
+
   return (
     <div className={styles.MaskedInput}>
       <input
         className="form-control"
         type={passwordShown ? 'text' : 'password'}
-        name={name}
         readOnly={readOnly}
-        value={value}
-        onChange={onChange}
         tabIndex={tabIndex}
+        {...inputProps}
       />
       <span onClick={togglePassword} className={styles.PasswordReveal}>
         {passwordShown ? (

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

@@ -1,20 +1,24 @@
 
 import React from 'react';
 
+import type { UseFormRegister } from 'react-hook-form';
+
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 type Props = {
-  adminAppContainer: AdminAppContainer,
+  adminAppContainer?: AdminAppContainer,
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  register: UseFormRegister<any>,
 }
 
-const SmtpSetting = (props: Props) => {
-  const { adminAppContainer } = props;
+const SesSetting = (props: Props): JSX.Element => {
+  const { register } = props;
 
   return (
     <React.Fragment>
-      <div id="mail-smtp" className="tab-pane active mt-5">
+      <div id="mail-ses" className="tab-pane active">
 
         <div className="row">
           <label className="text-start text-md-end col-md-3 col-form-label">
@@ -24,10 +28,7 @@ const SmtpSetting = (props: Props) => {
             <input
               className="form-control"
               type="text"
-              value={adminAppContainer.state.sesAccessKeyId || ''}
-              onChange={(e) => {
-                adminAppContainer.changeSesAccessKeyId(e.target.value);
-              }}
+              {...register('sesAccessKeyId')}
             />
           </div>
         </div>
@@ -40,10 +41,7 @@ const SmtpSetting = (props: Props) => {
             <input
               className="form-control"
               type="text"
-              value={adminAppContainer.state.sesSecretAccessKey || ''}
-              onChange={(e) => {
-                adminAppContainer.changeSesSecretAccessKey(e.target.value);
-              }}
+              {...register('sesSecretAccessKey')}
             />
           </div>
         </div>
@@ -53,9 +51,11 @@ const SmtpSetting = (props: Props) => {
   );
 };
 
+export { SesSetting };
+
 /**
  * Wrapper component for using unstated
  */
-const SmtpSettingWrapper = withUnstatedContainers(SmtpSetting, [AdminAppContainer]);
+const SesSettingWrapper = withUnstatedContainers(SesSetting, [AdminAppContainer]);
 
-export default SmtpSettingWrapper;
+export default SesSettingWrapper;

+ 22 - 9
apps/app/src/client/components/Admin/App/SiteUrlSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback } from 'react';
+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';
@@ -21,9 +22,23 @@ const SiteUrlSetting = (props: Props) => {
   const { t: tCommon } = useTranslation('commons');
   const { adminAppContainer } = props;
 
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
 
-  const submitHandler = useCallback(async() => {
+  // Reset form when adminAppContainer state changes
+  useEffect(() => {
+    reset({
+      siteUrl: adminAppContainer.state.siteUrl || '',
+    });
+  }, [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') }));
     }
@@ -34,7 +49,7 @@ const SiteUrlSetting = (props: Props) => {
   }, [adminAppContainer, t, tCommon]);
 
   return (
-    <React.Fragment>
+    <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>)}
@@ -69,11 +84,9 @@ const SiteUrlSetting = (props: Props) => {
                 <input
                   className="form-control"
                   type="text"
-                  name="settingForm[app:siteUrl]"
-                  value={adminAppContainer.state.siteUrl || ''}
-                  disabled={adminAppContainer.state.siteUrlUseOnlyEnvVars ?? true}
-                  onChange={(e) => { adminAppContainer.changeSiteUrl(e.target.value) }}
+                  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 */}
@@ -92,8 +105,8 @@ const SiteUrlSetting = (props: Props) => {
         </table>
       </div>
 
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-    </React.Fragment>
+      <AdminUpdateButtonRow type="submit" disabled={adminAppContainer.state.retrieveError != null} />
+    </form>
   );
 };
 

+ 13 - 12
apps/app/src/client/components/Admin/App/SmtpSetting.tsx

@@ -2,6 +2,7 @@
 import React from 'react';
 
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister } from 'react-hook-form';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 
@@ -9,16 +10,18 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 type Props = {
-  adminAppContainer: AdminAppContainer,
+  adminAppContainer?: AdminAppContainer,
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  register: UseFormRegister<any>,
 }
 
-const SmtpSetting = (props: Props) => {
+const SmtpSetting = (props: Props): JSX.Element => {
   const { t } = useTranslation();
-  const { adminAppContainer } = props;
+  const { register } = props;
 
   return (
     <React.Fragment>
-      <div id="mail-smtp" className="tab-pane active mt-5">
+      <div id="mail-smtp" className="tab-pane active">
         <div className="row">
           <label className="text-start text-md-end col-md-3 col-form-label">
             {t('admin:app_setting.host')}
@@ -27,8 +30,7 @@ const SmtpSetting = (props: Props) => {
             <input
               className="form-control"
               type="text"
-              value={adminAppContainer.state.smtpHost || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpHost(e.target.value) }}
+              {...register('smtpHost')}
             />
           </div>
         </div>
@@ -40,8 +42,7 @@ const SmtpSetting = (props: Props) => {
           <div className="col-md-6">
             <input
               className="form-control"
-              value={adminAppContainer.state.smtpPort || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpPort(e.target.value) }}
+              {...register('smtpPort')}
             />
           </div>
         </div>
@@ -54,8 +55,7 @@ const SmtpSetting = (props: Props) => {
             <input
               className="form-control"
               type="text"
-              value={adminAppContainer.state.smtpUser || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpUser(e.target.value) }}
+              {...register('smtpUser')}
             />
           </div>
         </div>
@@ -68,8 +68,7 @@ const SmtpSetting = (props: Props) => {
             <input
               className="form-control"
               type="password"
-              value={adminAppContainer.state.smtpPassword || ''}
-              onChange={(e) => { adminAppContainer.changeSmtpPassword(e.target.value) }}
+              {...register('smtpPassword')}
             />
           </div>
         </div>
@@ -78,6 +77,8 @@ const SmtpSetting = (props: Props) => {
   );
 };
 
+export { SmtpSetting };
+
 /**
  * Wrapper component for using unstated
  */

+ 395 - 0
apps/app/src/client/components/Admin/App/useFileUploadSettings.spec.ts

@@ -0,0 +1,395 @@
+import { describe, it, expect } from 'vitest';
+
+import type { FileUploadFormValues, FileUploadSettingsData } from './FileUploadSetting.types';
+
+/**
+ * Helper function to build settings data (mimics useFileUploadSettings fetchData logic)
+ */
+function buildSettingsData(appSettingsParams: Record<string, any>): FileUploadSettingsData {
+  return {
+    // File upload type
+    fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
+      ? appSettingsParams.envFileUploadType
+      : appSettingsParams.fileUploadType,
+    isFixedFileUploadByEnvVar: appSettingsParams.useOnlyEnvVarForFileUploadType || false,
+    envFileUploadType: appSettingsParams.envFileUploadType,
+
+    // AWS S3
+    s3Region: appSettingsParams.s3Region || '',
+    s3CustomEndpoint: appSettingsParams.s3CustomEndpoint || '',
+    s3Bucket: appSettingsParams.s3Bucket || '',
+    s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
+    s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
+    s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode || false,
+
+    // GCS
+    gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
+    gcsBucket: appSettingsParams.gcsBucket || '',
+    gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
+    gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode || false,
+    gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
+    envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
+    envGcsBucket: appSettingsParams.envGcsBucket,
+    envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
+
+    // Azure
+    azureTenantId: appSettingsParams.azureTenantId || '',
+    azureClientId: appSettingsParams.azureClientId || '',
+    azureClientSecret: appSettingsParams.azureClientSecret || '',
+    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,
+  };
+}
+
+/**
+ * Helper function to build request params (mimics useFileUploadSettings updateSettings logic)
+ */
+function buildRequestParams(
+    formData: FileUploadFormValues,
+    dirtyFields: Partial<Record<keyof FileUploadFormValues, boolean>>,
+): Record<string, any> {
+  const { fileUploadType } = formData;
+
+  const requestParams: Record<string, any> = {
+    fileUploadType,
+  };
+
+  if (fileUploadType === 'aws') {
+    requestParams.s3Region = formData.s3Region;
+    requestParams.s3CustomEndpoint = formData.s3CustomEndpoint;
+    requestParams.s3Bucket = formData.s3Bucket;
+    requestParams.s3AccessKeyId = formData.s3AccessKeyId;
+    // Only include secret access key if it was changed
+    if (dirtyFields.s3SecretAccessKey) {
+      requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
+    }
+    requestParams.s3ReferenceFileWithRelayMode = formData.s3ReferenceFileWithRelayMode;
+  }
+
+  if (fileUploadType === 'gcs') {
+    requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
+    requestParams.gcsBucket = formData.gcsBucket;
+    requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
+    requestParams.gcsReferenceFileWithRelayMode = formData.gcsReferenceFileWithRelayMode;
+  }
+
+  if (fileUploadType === 'azure') {
+    // Only include secret fields if they were changed
+    if (dirtyFields.azureTenantId) {
+      requestParams.azureTenantId = formData.azureTenantId;
+    }
+    if (dirtyFields.azureClientId) {
+      requestParams.azureClientId = formData.azureClientId;
+    }
+    if (dirtyFields.azureClientSecret) {
+      requestParams.azureClientSecret = formData.azureClientSecret;
+    }
+    requestParams.azureStorageAccountName = formData.azureStorageAccountName;
+    requestParams.azureStorageContainerName = formData.azureStorageContainerName;
+    requestParams.azureReferenceFileWithRelayMode = formData.azureReferenceFileWithRelayMode;
+  }
+
+  return requestParams;
+}
+
+describe('useFileUploadSettings - fileUploadType selection with useOnlyEnvVarForFileUploadType', () => {
+  it('should use envFileUploadType when useOnlyEnvVarForFileUploadType is true', () => {
+    const appSettingsParams = {
+      fileUploadType: 'local',
+      envFileUploadType: 'aws',
+      useOnlyEnvVarForFileUploadType: true,
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    expect(settingsData.fileUploadType).toBe('aws');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(true);
+    expect(settingsData.envFileUploadType).toBe('aws');
+  });
+
+  it('should use fileUploadType when useOnlyEnvVarForFileUploadType is false', () => {
+    const appSettingsParams = {
+      fileUploadType: 'gcs',
+      envFileUploadType: 'aws',
+      useOnlyEnvVarForFileUploadType: false,
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    expect(settingsData.fileUploadType).toBe('gcs');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(false);
+    expect(settingsData.envFileUploadType).toBe('aws');
+  });
+
+  it('should use fileUploadType when useOnlyEnvVarForFileUploadType is undefined', () => {
+    const appSettingsParams = {
+      fileUploadType: 'azure',
+      envFileUploadType: 'aws',
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    expect(settingsData.fileUploadType).toBe('azure');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(false);
+  });
+
+  it('should prioritize envFileUploadType over fileUploadType when env var is enforced', () => {
+    const appSettingsParams = {
+      fileUploadType: 'local',
+      envFileUploadType: 'gcs',
+      useOnlyEnvVarForFileUploadType: true,
+    };
+
+    const settingsData = buildSettingsData(appSettingsParams);
+
+    // Even though DB has 'local', env var 'gcs' should be used
+    expect(settingsData.fileUploadType).toBe('gcs');
+    expect(settingsData.isFixedFileUploadByEnvVar).toBe(true);
+  });
+});
+
+describe('useFileUploadSettings - secret field dirty tracking', () => {
+  it('should NOT include s3SecretAccessKey in request when it is not dirty (AWS)', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: '***existing-secret***', // Not changed
+      s3ReferenceFileWithRelayMode: true,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      azureReferenceFileWithRelayMode: false,
+    };
+
+    const dirtyFields = {
+      s3Region: true,
+      s3Bucket: true,
+      s3AccessKeyId: true,
+      s3ReferenceFileWithRelayMode: true,
+      // s3SecretAccessKey is NOT marked as dirty
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toEqual({
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3ReferenceFileWithRelayMode: true,
+    });
+
+    // Verify s3SecretAccessKey is NOT in the request
+    expect(requestParams).not.toHaveProperty('s3SecretAccessKey');
+  });
+
+  it('should include s3SecretAccessKey in request when it is dirty (AWS)', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: 'new-secret-key', // Changed
+      s3ReferenceFileWithRelayMode: true,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      azureReferenceFileWithRelayMode: false,
+    };
+
+    const dirtyFields = {
+      s3Region: true,
+      s3Bucket: true,
+      s3AccessKeyId: true,
+      s3SecretAccessKey: true, // Marked as dirty
+      s3ReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toEqual({
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: 'new-secret-key',
+      s3ReferenceFileWithRelayMode: true,
+    });
+  });
+
+  it('should include empty string for s3SecretAccessKey when explicitly set to empty (AWS)', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'aws',
+      s3Region: 'us-west-2',
+      s3CustomEndpoint: '',
+      s3Bucket: 'new-bucket',
+      s3AccessKeyId: 'new-key-id',
+      s3SecretAccessKey: '', // Explicitly cleared
+      s3ReferenceFileWithRelayMode: true,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '',
+      azureClientId: '',
+      azureClientSecret: '',
+      azureStorageAccountName: '',
+      azureStorageContainerName: '',
+      azureReferenceFileWithRelayMode: false,
+    };
+
+    const dirtyFields = {
+      s3Region: true,
+      s3Bucket: true,
+      s3AccessKeyId: true,
+      s3SecretAccessKey: true, // Marked as dirty
+      s3ReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toHaveProperty('s3SecretAccessKey', '');
+  });
+
+  it('should NOT include Azure secret fields in request when they are not dirty', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'azure',
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: '***existing-tenant***', // Not changed
+      azureClientId: '***existing-client***', // Not changed
+      azureClientSecret: '***existing-secret***', // Not changed
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const dirtyFields = {
+      azureStorageAccountName: true,
+      azureStorageContainerName: true,
+      azureReferenceFileWithRelayMode: true,
+      // Azure secret fields are NOT marked as dirty
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    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');
+  });
+
+  it('should include Azure secret fields in request when they are dirty', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'azure',
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: 'new-tenant-id',
+      azureClientId: 'new-client-id',
+      azureClientSecret: 'new-client-secret',
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const dirtyFields = {
+      azureTenantId: true,
+      azureClientId: true,
+      azureClientSecret: true,
+      azureStorageAccountName: true,
+      azureStorageContainerName: true,
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toEqual({
+      fileUploadType: 'azure',
+      azureTenantId: 'new-tenant-id',
+      azureClientId: 'new-client-id',
+      azureClientSecret: 'new-client-secret',
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    });
+  });
+
+  it('should include only some Azure secret fields when only some are dirty', () => {
+    const formData: FileUploadFormValues = {
+      fileUploadType: 'azure',
+      s3Region: '',
+      s3CustomEndpoint: '',
+      s3Bucket: '',
+      s3AccessKeyId: '',
+      s3SecretAccessKey: '',
+      s3ReferenceFileWithRelayMode: false,
+      gcsApiKeyJsonPath: '',
+      gcsBucket: '',
+      gcsUploadNamespace: '',
+      gcsReferenceFileWithRelayMode: false,
+      azureTenantId: 'new-tenant-id',
+      azureClientId: '***existing-client***', // Not changed
+      azureClientSecret: 'new-client-secret',
+      azureStorageAccountName: 'new-account',
+      azureStorageContainerName: 'new-container',
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const dirtyFields = {
+      azureTenantId: true, // Marked as dirty
+      // azureClientId is NOT marked as dirty
+      azureClientSecret: true, // Marked as dirty
+      azureStorageAccountName: true,
+      azureStorageContainerName: true,
+      azureReferenceFileWithRelayMode: true,
+    };
+
+    const requestParams = buildRequestParams(formData, dirtyFields);
+
+    expect(requestParams).toHaveProperty('azureTenantId', 'new-tenant-id');
+    expect(requestParams).not.toHaveProperty('azureClientId');
+    expect(requestParams).toHaveProperty('azureClientSecret', 'new-client-secret');
+  });
+});

+ 141 - 0
apps/app/src/client/components/Admin/App/useFileUploadSettings.ts

@@ -0,0 +1,141 @@
+import { useState, useEffect } from 'react';
+
+import type { FieldNamesMarkedBoolean } from 'react-hook-form';
+
+import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+
+import type { FileUploadSettingsData, FileUploadFormValues } from './FileUploadSetting.types';
+
+type UseFileUploadSettingsReturn = {
+  data: FileUploadSettingsData | null
+  isLoading: boolean
+  error: Error | null
+  updateSettings: (formData: FileUploadFormValues, dirtyFields: FieldNamesMarkedBoolean<FileUploadFormValues>) => Promise<void>
+};
+
+export function useFileUploadSettings(): UseFileUploadSettingsReturn {
+  const [data, setData] = useState<FileUploadSettingsData | null>(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<Error | null>(null);
+
+  useEffect(() => {
+    const fetchData = async() => {
+      try {
+        setIsLoading(true);
+        const response = await apiv3Get('/app-settings/');
+        const { appSettingsParams } = response.data;
+
+        const settingsData: FileUploadSettingsData = {
+          // File upload type
+          fileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType
+            ? appSettingsParams.envFileUploadType
+            : appSettingsParams.fileUploadType,
+          isFixedFileUploadByEnvVar: appSettingsParams.useOnlyEnvVarForFileUploadType || false,
+          envFileUploadType: appSettingsParams.envFileUploadType,
+
+          // AWS S3
+          s3Region: appSettingsParams.s3Region || '',
+          s3CustomEndpoint: appSettingsParams.s3CustomEndpoint || '',
+          s3Bucket: appSettingsParams.s3Bucket || '',
+          s3AccessKeyId: appSettingsParams.s3AccessKeyId || '',
+          s3SecretAccessKey: appSettingsParams.s3SecretAccessKey || '',
+          s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode || false,
+
+          // GCS
+          gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath || '',
+          gcsBucket: appSettingsParams.gcsBucket || '',
+          gcsUploadNamespace: appSettingsParams.gcsUploadNamespace || '',
+          gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode || false,
+          gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars || false,
+          envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
+          envGcsBucket: appSettingsParams.envGcsBucket,
+          envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
+
+          // Azure
+          azureTenantId: appSettingsParams.azureTenantId || '',
+          azureClientId: appSettingsParams.azureClientId || '',
+          azureClientSecret: appSettingsParams.azureClientSecret || '',
+          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,
+        };
+
+        setData(settingsData);
+        setError(null);
+      }
+      catch (err) {
+        setError(err instanceof Error ? err : new Error('Failed to fetch settings'));
+      }
+      finally {
+        setIsLoading(false);
+      }
+    };
+
+    fetchData();
+  }, []);
+
+  const updateSettings = async(formData: FileUploadFormValues, dirtyFields: FieldNamesMarkedBoolean<FileUploadFormValues>): Promise<void> => {
+    const { fileUploadType } = formData;
+
+    const requestParams: Record<string, any> = {
+      fileUploadType,
+    };
+
+    // Add fields based on upload type
+    if (fileUploadType === 'aws') {
+      requestParams.s3Region = formData.s3Region;
+      requestParams.s3CustomEndpoint = formData.s3CustomEndpoint;
+      requestParams.s3Bucket = formData.s3Bucket;
+      requestParams.s3AccessKeyId = formData.s3AccessKeyId;
+      // Only include secret access key if it was changed
+      if (dirtyFields.s3SecretAccessKey) {
+        requestParams.s3SecretAccessKey = formData.s3SecretAccessKey;
+      }
+      requestParams.s3ReferenceFileWithRelayMode = formData.s3ReferenceFileWithRelayMode;
+    }
+
+    if (fileUploadType === 'gcs') {
+      requestParams.gcsApiKeyJsonPath = formData.gcsApiKeyJsonPath;
+      requestParams.gcsBucket = formData.gcsBucket;
+      requestParams.gcsUploadNamespace = formData.gcsUploadNamespace;
+      requestParams.gcsReferenceFileWithRelayMode = formData.gcsReferenceFileWithRelayMode;
+    }
+
+    if (fileUploadType === 'azure') {
+      // Only include secret fields if they were changed
+      if (dirtyFields.azureTenantId) {
+        requestParams.azureTenantId = formData.azureTenantId;
+      }
+      if (dirtyFields.azureClientId) {
+        requestParams.azureClientId = formData.azureClientId;
+      }
+      if (dirtyFields.azureClientSecret) {
+        requestParams.azureClientSecret = formData.azureClientSecret;
+      }
+      requestParams.azureStorageAccountName = formData.azureStorageAccountName;
+      requestParams.azureStorageContainerName = formData.azureStorageContainerName;
+      requestParams.azureReferenceFileWithRelayMode = formData.azureReferenceFileWithRelayMode;
+    }
+
+    const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
+    const { responseParams } = response.data;
+
+    // Update local state with response
+    if (data) {
+      setData({
+        ...data,
+        ...responseParams,
+      });
+    }
+  };
+
+  return {
+    data, isLoading, error, updateSettings,
+  };
+}

+ 13 - 3
apps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx

@@ -3,8 +3,9 @@ import React, { type JSX } from 'react';
 import { useTranslation } from 'next-i18next';
 
 type Props = {
-  onClick: () => void,
+  onClick?: () => void,
   disabled?: boolean,
+  type?: 'button' | 'submit' | 'reset',
 }
 
 const AdminUpdateButtonRow = (props: Props): JSX.Element => {
@@ -12,8 +13,17 @@ const AdminUpdateButtonRow = (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 ?? false}>{ t('Update') }</button>
+      <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') }
+        </button>
       </div>
     </div>
   );

+ 29 - 13
apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback, useEffect, type JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { Card, CardBody } from 'reactstrap';
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
@@ -18,8 +19,23 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const onClickSubmit = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      customizeCss: adminCustomizeContainer.state.currentCustomizeCss || '',
+    });
+  }, [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' }));
     }
@@ -41,17 +57,17 @@ const CustomizeCssSetting = (props: Props): JSX.Element => {
             </CardBody>
           </Card>
 
-          <div>
-            <textarea
-              className="form-control"
-              name="customizeCss"
-              rows={8}
-              value={adminCustomizeContainer.state.currentCustomizeCss || ''}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeCss(e.target.value) }}
-            />
-          </div>
-
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+          <form onSubmit={handleSubmit(onSubmit)}>
+            <div>
+              <textarea
+                className="form-control"
+                rows={8}
+                {...register('customizeCss')}
+              />
+            </div>
+
+            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+          </form>
         </div>
       </div>
     </React.Fragment>

+ 47 - 31
apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback, useEffect, type JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { Card, CardBody } from 'reactstrap';
@@ -20,8 +21,23 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const onClickSubmit = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      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' }));
     }
@@ -45,40 +61,40 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
             </CardBody>
           </Card>
 
-          <div>
-            <textarea
-              className="form-control mb-2"
-              name="customizeNoscript"
-              rows={8}
-              value={adminCustomizeContainer.state.currentCustomizeNoscript || ''}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeNoscript(e.target.value) }}
-            />
-          </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"
+          <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"
             >
-              {`<iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX"
+              <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>
+              </PrismAsyncLight>
+            </div>
 
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+          </form>
         </div>
       </div>
     </React.Fragment>

+ 47 - 31
apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx

@@ -1,6 +1,7 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback, useEffect, type JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 import { Card, CardBody } from 'reactstrap';
@@ -20,8 +21,23 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
 
-  const onClickSubmit = useCallback(async() => {
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      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' }));
     }
@@ -42,33 +58,32 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
             </CardBody>
           </Card>
 
-          <div>
-            <textarea
-              className="form-control mb-2"
-              name="customizeScript"
-              rows={8}
-              value={adminCustomizeContainer.state.currentCustomizeScript || ''}
-              onChange={(e) => { adminCustomizeContainer.changeCustomizeScript(e.target.value) }}
-            />
-          </div>
-
-          <a
-            className="text-muted"
-            data-bs-toggle="collapse"
-            href="#collapseExampleScript"
-            role="button"
-            aria-expanded="false"
-            aria-controls="collapseExampleScript"
-          >
-            <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
-            Example for Google Tag Manager
-          </a>
-          <div className="collapse" id="collapseExampleScript">
-            <PrismAsyncLight
-              style={oneDark}
-              language="javascript"
+          <form onSubmit={handleSubmit(onSubmit)}>
+            <div>
+              <textarea
+                className="form-control mb-2"
+                rows={8}
+                {...register('customizeScript')}
+              />
+            </div>
+
+            <a
+              className="text-muted"
+              data-bs-toggle="collapse"
+              href="#collapseExampleScript"
+              role="button"
+              aria-expanded="false"
+              aria-controls="collapseExampleScript"
             >
-              {`(function(w,d,s,l,i){
+              <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
+              Example for Google Tag Manager
+            </a>
+            <div className="collapse" id="collapseExampleScript">
+              <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'});
 var f=d.getElementsByTagName(s)[0],
@@ -77,10 +92,11 @@ var f=d.getElementsByTagName(s)[0],
 j.async=true;
 j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
 })(window,document,'script','dataLayer','GTM-XXXXXX');`}
-            </PrismAsyncLight>
-          </div>
+              </PrismAsyncLight>
+            </div>
 
-          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+            <AdminUpdateButtonRow type="submit" disabled={adminCustomizeContainer.state.retrieveError != null} />
+          </form>
         </div>
       </div>
     </React.Fragment>

+ 28 - 15
apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx

@@ -1,7 +1,8 @@
 import type { FC } from 'react';
-import React, { useState } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 import { Card, CardBody } from 'reactstrap';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
@@ -16,19 +17,30 @@ export const CustomizeTitle: FC = () => {
 
   const { data: customizeTitle } = useCustomizeTitle();
 
-  const [currentCustomizeTitle, setCrrentCustomizeTitle] = useState(customizeTitle ?? '');
+  const {
+    register,
+    handleSubmit,
+    reset,
+  } = useForm();
 
-  const onClickSubmit = async() => {
+  // Sync form with store data
+  useEffect(() => {
+    reset({
+      customizeTitle: customizeTitle ?? '',
+    });
+  }, [customizeTitle, reset]);
+
+  const onSubmit = useCallback(async(data) => {
     try {
       await apiv3Put('/customize-setting/customize-title', {
-        customizeTitle: currentCustomizeTitle,
+        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>
@@ -64,16 +76,17 @@ export const CustomizeTitle: FC = () => {
           <br />
           Default Output Example: <code className="xml">&lt;title&gt;Page name - My GROWI&lt;&#047;title&gt;</code>
         </div>
-        <div className="col-12">
-          <input
-            className="form-control"
-            value={currentCustomizeTitle}
-            onChange={(e) => { setCrrentCustomizeTitle(e.target.value) }}
-          />
-        </div>
-        <div className="col-12">
-          <AdminUpdateButtonRow onClick={onClickSubmit} 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>
   );

+ 210 - 205
apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 
 import AdminImportContainer from '~/client/services/AdminImportContainer';
 import { toastError } from '~/client/util/toastr';
@@ -14,233 +15,237 @@ import GrowiArchiveSection from './GrowiArchiveSection';
 
 const logger = loggerFactory('growi:importer');
 
-class ImportDataPageContents extends React.Component {
-
-  render() {
-    const { t, adminImportContainer } = this.props;
-
-    return (
-      <div data-testid="admin-import-data">
-        <GrowiArchiveSection />
-
-        <form
-          className="mt-5"
-          id="importerSettingFormEsa"
-          role="form"
-        >
-          <fieldset>
-            <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'esa.io' })}</h2>
-            <table className="table table-bordered table-mapping">
-              <thead>
-                <tr>
-                  <th width="45%">esa.io</th>
-                  <th width="10%"></th>
-                  <th>GROWI</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <th>{t('importer_management.article')}</th>
-                  <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
-                  <th>{t('importer_management.page')}</th>
-                </tr>
-                <tr>
-                  <th>{t('importer_management.category')}</th>
-                  <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
-                  <th>{t('importer_management.page_path')}</th>
-                </tr>
-                <tr>
-                  <th>{t('User')}</th>
-                  <th></th>
-                  <th>(TBD)</th>
-                </tr>
-              </tbody>
-            </table>
-
-            <div className="card custom-card bg-body-tertiary mb-0 small">
-              <ul>
-                <li>{t('importer_management.page_skip')}</li>
-              </ul>
-            </div>
-
-            <div className="row mt-4">
-              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
-            </div>
+const ImportDataPageContents = ({ t, adminImportContainer }) => {
+  const { register: registerEsa, reset: resetEsa, handleSubmit: handleSubmitEsa } = useForm();
+  const { register: registerQiita, reset: resetQiita, handleSubmit: handleSubmitQiita } = useForm();
 
-            <div className="row">
-              <label htmlFor="settingForm[importer:esa:team_name]" className="text-start text-md-end col-md-3 col-form-label">
-                {t('importer_management.esa_settings.team_name')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="esaTeamName"
-                  value={adminImportContainer.state.esaTeamName || ''}
-                  onChange={adminImportContainer.handleInputValue}
-                />
-              </div>
+  useEffect(() => {
+    resetEsa({
+      esaTeamName: adminImportContainer.state.esaTeamName || '',
+      esaAccessToken: adminImportContainer.state.esaAccessToken || '',
+    });
+  }, [resetEsa, adminImportContainer.state.esaTeamName, adminImportContainer.state.esaAccessToken]);
 
+  useEffect(() => {
+    resetQiita({
+      qiitaTeamName: adminImportContainer.state.qiitaTeamName || '',
+      qiitaAccessToken: adminImportContainer.state.qiitaAccessToken || '',
+    });
+  }, [resetQiita, adminImportContainer.state.qiitaTeamName, adminImportContainer.state.qiitaAccessToken]);
+
+  return (
+    <div data-testid="admin-import-data">
+      <GrowiArchiveSection />
+
+      <form
+        className="mt-5"
+        id="importerSettingFormEsa"
+        role="form"
+        onSubmit={handleSubmitEsa(adminImportContainer.esaHandleSubmitUpdate)}
+      >
+        <fieldset>
+          <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'esa.io' })}</h2>
+          <table className="table table-bordered table-mapping">
+            <thead>
+              <tr>
+                <th width="45%">esa.io</th>
+                <th width="10%"></th>
+                <th>GROWI</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <th>{t('importer_management.article')}</th>
+                <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
+                <th>{t('importer_management.page')}</th>
+              </tr>
+              <tr>
+                <th>{t('importer_management.category')}</th>
+                <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
+                <th>{t('importer_management.page_path')}</th>
+              </tr>
+              <tr>
+                <th>{t('User')}</th>
+                <th></th>
+                <th>(TBD)</th>
+              </tr>
+            </tbody>
+          </table>
+
+          <div className="card custom-card bg-body-tertiary mb-0 small">
+            <ul>
+              <li>{t('importer_management.page_skip')}</li>
+            </ul>
+          </div>
+
+          <div className="row mt-4">
+            <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+          </div>
+
+          <div className="row">
+            <label htmlFor="settingForm[importer:esa:team_name]" className="text-start text-md-end col-md-3 col-form-label">
+              {t('importer_management.esa_settings.team_name')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...registerEsa('esaTeamName')}
+              />
             </div>
 
-            <div className="row mt-3">
-              <label htmlFor="settingForm[importer:esa:access_token]" className="text-start text-md-end col-md-3 col-form-label">
-                {t('importer_management.esa_settings.access_token')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="password"
-                  name="esaAccessToken"
-                  value={adminImportContainer.state.esaAccessToken || ''}
-                  onChange={adminImportContainer.handleInputValue}
-                />
-              </div>
+          </div>
+
+          <div className="row mt-3">
+            <label htmlFor="settingForm[importer:esa:access_token]" className="text-start text-md-end col-md-3 col-form-label">
+              {t('importer_management.esa_settings.access_token')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="password"
+                {...registerEsa('esaAccessToken')}
+              />
             </div>
-
-            <div className="row mt-3">
-              <div className="offset-md-3 col-md-6">
+          </div>
+
+          <div className="row mt-3">
+            <div className="offset-md-3 col-md-6">
+              <input
+                id="testConnectionToEsa"
+                type="button"
+                className="btn btn-primary btn-esa me-3"
+                name="Esa"
+                onClick={adminImportContainer.esaHandleSubmit}
+                value={t('importer_management.import')}
+              />
+              <input type="submit" className="btn btn-secondary" value={t('Update')} />
+              <span className="offset-0 offset-sm-1">
                 <input
-                  id="testConnectionToEsa"
+                  id="importFromEsa"
                   type="button"
-                  className="btn btn-primary btn-esa me-3"
                   name="Esa"
-                  onClick={adminImportContainer.esaHandleSubmit}
-                  value={t('importer_management.import')}
+                  className="btn btn-secondary btn-esa"
+                  onClick={adminImportContainer.esaHandleSubmitTest}
+                  value={t('importer_management.esa_settings.test_connection')}
                 />
-                <input type="button" className="btn btn-secondary" onClick={adminImportContainer.esaHandleSubmitUpdate} value={t('Update')} />
-                <span className="offset-0 offset-sm-1">
-                  <input
-                    id="importFromEsa"
-                    type="button"
-                    name="Esa"
-                    className="btn btn-secondary btn-esa"
-                    onClick={adminImportContainer.esaHandleSubmitTest}
-                    value={t('importer_management.esa_settings.test_connection')}
-                  />
-                </span>
-
-              </div>
+              </span>
             </div>
-          </fieldset>
-        </form>
-
-        <form
-          className="mt-5"
-          id="importerSettingFormQiita"
-          role="form"
-        >
-          <fieldset>
-            <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'Qiita:Team' })}</h2>
-            <table className="table table-bordered table-mapping">
-              <thead>
-                <tr>
-                  <th width="45%">Qiita:Team</th>
-                  <th width="10%"></th>
-                  <th>GROWI</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <th>{t('importer_management.article')}</th>
-                  <th><span className="material-symbols-outlined">arrow_circle_right</span></th>
-                  <th>{t('importer_management.page')}</th>
-                </tr>
-                <tr>
-                  <th>{t('importer_management.tag')}</th>
-                  <th></th>
-                  <th>-</th>
-                </tr>
-                <tr>
-                  <th>{t('importer_management.Directory_hierarchy_tag')}</th>
-                  <th></th>
-                  <th>(TBD)</th>
-                </tr>
-                <tr>
-                  <th>{t('User')}</th>
-                  <th></th>
-                  <th>(TBD)</th>
-                </tr>
-              </tbody>
-            </table>
-            <div className="card custom-card bg-body-tertiary mb-3 small">
-              <ul>
-                <li>{t('importer_management.page_skip')}</li>
-              </ul>
+          </div>
+        </fieldset>
+      </form>
+
+      <form
+        className="mt-5"
+        id="importerSettingFormQiita"
+        role="form"
+        onSubmit={handleSubmitQiita(adminImportContainer.qiitaHandleSubmitUpdate)}
+      >
+        <fieldset>
+          <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'Qiita:Team' })}</h2>
+          <table className="table table-bordered table-mapping">
+            <thead>
+              <tr>
+                <th width="45%">Qiita:Team</th>
+                <th width="10%"></th>
+                <th>GROWI</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <th>{t('importer_management.article')}</th>
+                <th><span className="material-symbols-outlined">arrow_circle_right</span></th>
+                <th>{t('importer_management.page')}</th>
+              </tr>
+              <tr>
+                <th>{t('importer_management.tag')}</th>
+                <th></th>
+                <th>-</th>
+              </tr>
+              <tr>
+                <th>{t('importer_management.Directory_hierarchy_tag')}</th>
+                <th></th>
+                <th>(TBD)</th>
+              </tr>
+              <tr>
+                <th>{t('User')}</th>
+                <th></th>
+                <th>(TBD)</th>
+              </tr>
+            </tbody>
+          </table>
+          <div className="card custom-card bg-body-tertiary mb-3 small">
+            <ul>
+              <li>{t('importer_management.page_skip')}</li>
+            </ul>
+          </div>
+
+          <div className="row mt-3">
+            <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+          </div>
+          <div className="row mt-3">
+            <label htmlFor="settingForm[importer:qiita:team_name]" className="text-start text-md-end col-md-3 col-form-label">
+              {t('importer_management.qiita_settings.team_name')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...registerQiita('qiitaTeamName')}
+              />
             </div>
-
-            <div className="row mt-3">
-              <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
+          </div>
+
+          <div className="row mt-3">
+            <label htmlFor="settingForm[importer:qiita:access_token]" className="text-start text-md-end col-md-3 col-form-label">
+              {t('importer_management.qiita_settings.access_token')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="password"
+                {...registerQiita('qiitaAccessToken')}
+              />
             </div>
-            <div className="row mt-3">
-              <label htmlFor="settingForm[importer:qiita:team_name]" className="text-start text-md-end col-md-3 col-form-label">
-                {t('importer_management.qiita_settings.team_name')}
-              </label>
-              <div className="col-md-6">
+          </div>
+
+
+          <div className="row mt-3">
+            <div className="offset-md-3 col-md-6">
+              <input
+                id="testConnectionToQiita"
+                type="button"
+                className="btn btn-primary btn-qiita me-3"
+                name="Qiita"
+                onClick={adminImportContainer.qiitaHandleSubmit}
+                value={t('importer_management.import')}
+              />
+              <input type="submit" className="btn btn-secondary" value={t('Update')} />
+              <span className="offset-0 offset-sm-1">
                 <input
-                  className="form-control"
-                  type="text"
-                  name="qiitaTeamName"
-                  value={adminImportContainer.state.qiitaTeamName || ''}
-                  onChange={adminImportContainer.handleInputValue}
-                />
-              </div>
-            </div>
-
-            <div className="row mt-3">
-              <label htmlFor="settingForm[importer:qiita:access_token]" className="text-start text-md-end col-md-3 col-form-label">
-                {t('importer_management.qiita_settings.access_token')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="password"
-                  name="qiitaAccessToken"
-                  value={adminImportContainer.state.qiitaAccessToken || ''}
-                  onChange={adminImportContainer.handleInputValue}
-                />
-              </div>
-            </div>
-
-
-            <div className="row mt-3">
-              <div className="offset-md-3 col-md-6">
-                <input
-                  id="testConnectionToQiita"
-                  type="button"
-                  className="btn btn-primary btn-qiita me-3"
                   name="Qiita"
-                  onClick={adminImportContainer.qiitaHandleSubmit}
-                  value={t('importer_management.import')}
+                  type="button"
+                  id="importFromQiita"
+                  className="btn btn-secondary btn-qiita"
+                  onClick={adminImportContainer.qiitaHandleSubmitTest}
+                  value={t('importer_management.qiita_settings.test_connection')}
                 />
-                <input type="button" className="btn btn-secondary" onClick={adminImportContainer.qiitaHandleSubmitUpdate} value={t('Update')} />
-                <span className="offset-0 offset-sm-1">
-                  <input
-                    name="Qiita"
-                    type="button"
-                    id="importFromQiita"
-                    className="btn btn-secondary btn-qiita"
-                    onClick={adminImportContainer.qiitaHandleSubmitTest}
-                    value={t('importer_management.qiita_settings.test_connection')}
-                  />
-                </span>
-
-              </div>
-            </div>
+              </span>
 
+            </div>
+          </div>
 
-          </fieldset>
 
+        </fieldset>
 
-        </form>
-      </div>
-    );
-  }
 
-}
+      </form>
+    </div>
+  );
+};
 
 ImportDataPageContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
+  t: PropTypes.func.isRequired,
   adminImportContainer: PropTypes.instanceOf(AdminImportContainer).isRequired,
 };
 

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

@@ -1,7 +1,8 @@
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
@@ -12,18 +13,24 @@ import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 const logger = loggerFactory('growi:slackAppConfiguration');
 
-class SlackConfiguration extends React.Component {
+const SlackConfiguration = (props) => {
+  const { t, adminSlackIntegrationLegacyContainer } = props;
+  const { webhookUrl, slackToken, retrieveError } = adminSlackIntegrationLegacyContainer.state;
 
-  constructor(props) {
-    super(props);
+  const { register, handleSubmit, reset } = useForm();
 
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminSlackIntegrationLegacyContainer } = this.props;
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      webhookUrl,
+      slackToken,
+    });
+  }, [reset, webhookUrl, slackToken]);
 
+  const onClickSubmit = useCallback(async(data) => {
     try {
+      await adminSlackIntegrationLegacyContainer.changeWebhookUrl(data.webhookUrl ?? '');
+      await adminSlackIntegrationLegacyContainer.changeSlackToken(data.slackToken ?? '');
       await adminSlackIntegrationLegacyContainer.updateSlackAppConfiguration();
       toastSuccess(t('notification_settings.updated_slackApp'));
     }
@@ -31,12 +38,10 @@ class SlackConfiguration extends React.Component {
       toastError(err);
       logger.error(err);
     }
-  }
-
-  render() {
-    const { t, adminSlackIntegrationLegacyContainer } = this.props;
+  }, [adminSlackIntegrationLegacyContainer, t]);
 
-    return (
+  return (
+    <form onSubmit={handleSubmit(onClickSubmit)}>
       <React.Fragment>
         <div className="row my-3">
           <div className="col-6 text-start">
@@ -70,8 +75,7 @@ class SlackConfiguration extends React.Component {
                 <input
                   className="form-control"
                   type="text"
-                  value={adminSlackIntegrationLegacyContainer.state.webhookUrl || ''}
-                  onChange={e => adminSlackIntegrationLegacyContainer.changeWebhookUrl(e.target.value)}
+                  {...register('webhookUrl')}
                 />
               </div>
             </div>
@@ -122,8 +126,7 @@ class SlackConfiguration extends React.Component {
                   <input
                     className="form-control"
                     type="text"
-                    value={adminSlackIntegrationLegacyContainer.state.slackToken || ''}
-                    onChange={e => adminSlackIntegrationLegacyContainer.changeSlackToken(e.target.value)}
+                    {...register('slackToken')}
                   />
                 </div>
               </div>
@@ -133,8 +136,8 @@ class SlackConfiguration extends React.Component {
         }
 
         <AdminUpdateButtonRow
-          onClick={this.onClickSubmit}
-          disabled={adminSlackIntegrationLegacyContainer.state.retrieveError != null}
+          disabled={retrieveError != null}
+          onClick={handleSubmit(onClickSubmit)}
         />
 
         <hr />
@@ -149,7 +152,7 @@ class SlackConfiguration extends React.Component {
             {t('notification_settings.how_to.workspace')}
             <ol>
               {/* eslint-disable-next-line react/no-danger */}
-              <li dangerouslySetInnerHTML={{ __html:  t('notification_settings.how_to.workspace_desc1') }} />
+              <li dangerouslySetInnerHTML={{ __html: t('notification_settings.how_to.workspace_desc1') }} />
               <li>{t('notification_settings.how_to.workspace_desc2')}</li>
               <li>{t('notification_settings.how_to.workspace_desc3')}</li>
             </ol>
@@ -164,16 +167,13 @@ class SlackConfiguration extends React.Component {
         </ol>
 
       </React.Fragment>
-    );
-  }
-
-}
-
+    </form>
+  );
+};
 
 SlackConfiguration.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminSlackIntegrationLegacyContainer: PropTypes.instanceOf(AdminSlackIntegrationLegacyContainer).isRequired,
-
 };
 
 const SlackConfigurationWrapperFc = (props) => {

+ 17 - 26
apps/app/src/client/components/Admin/MarkdownSetting/WhitelistInput.tsx

@@ -1,41 +1,38 @@
-import { useCallback, useRef, type JSX } from 'react';
+import { useCallback, type JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister, UseFormSetValue } from 'react-hook-form';
 
 import type AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from '~/services/renderer/recommended-whitelist';
 
+type FormValues = {
+  tagWhitelist: string,
+  attrWhitelist: string,
+}
+
 type Props ={
-  adminMarkDownContainer: AdminMarkDownContainer
+  adminMarkDownContainer: AdminMarkDownContainer,
+  register: UseFormRegister<FormValues>,
+  setValue: UseFormSetValue<FormValues>,
 }
 
 export const WhitelistInput = (props: Props): JSX.Element => {
 
   const { t } = useTranslation('admin');
-  const { adminMarkDownContainer } = props;
-
-  const tagNamesRef = useRef<HTMLTextAreaElement>(null);
-  const attrsRef = useRef<HTMLTextAreaElement>(null);
+  const { adminMarkDownContainer, register, setValue } = props;
 
   const clickRecommendTagButtonHandler = useCallback(() => {
-    if (tagNamesRef.current == null) {
-      return;
-    }
-
     const tagWhitelist = recommendedTagNames.join(',');
-    tagNamesRef.current.value = tagWhitelist;
+    setValue('tagWhitelist', tagWhitelist);
     adminMarkDownContainer.setState({ tagWhitelist });
-  }, [adminMarkDownContainer]);
+  }, [adminMarkDownContainer, setValue]);
 
   const clickRecommendAttrButtonHandler = useCallback(() => {
-    if (attrsRef.current == null) {
-      return;
-    }
-
     const attrWhitelist = JSON.stringify(recommendedAttributes);
-    attrsRef.current.value = attrWhitelist;
+    setValue('attrWhitelist', attrWhitelist);
     adminMarkDownContainer.setState({ attrWhitelist });
-  }, [adminMarkDownContainer]);
+  }, [adminMarkDownContainer, setValue]);
 
   return (
     <>
@@ -47,13 +44,10 @@ export const WhitelistInput = (props: Props): JSX.Element => {
           </p>
         </div>
         <textarea
-          ref={tagNamesRef}
           className="form-control xss-list"
-          name="recommendedTags"
           rows={6}
           cols={40}
-          value={adminMarkDownContainer.state.tagWhitelist}
-          onChange={(e) => { adminMarkDownContainer.setState({ tagWhitelist: e.target.value }) }}
+          {...register('tagWhitelist')}
         />
       </div>
       <div className="mt-4">
@@ -64,13 +58,10 @@ export const WhitelistInput = (props: Props): JSX.Element => {
           </p>
         </div>
         <textarea
-          ref={attrsRef}
           className="form-control xss-list"
-          name="recommendedAttrs"
           rows={6}
           cols={40}
-          value={adminMarkDownContainer.state.attrWhitelist}
-          onChange={(e) => { adminMarkDownContainer.setState({ attrWhitelist: e.target.value }) }}
+          {...register('attrWhitelist')}
         />
       </div>
     </>

+ 39 - 30
apps/app/src/client/components/Admin/MarkdownSetting/XssForm.jsx

@@ -1,7 +1,8 @@
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 
 import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
@@ -16,30 +17,38 @@ import { WhitelistInput } from './WhitelistInput';
 
 const logger = loggerFactory('growi:importer');
 
-class XssForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t } = this.props;
-
+const XssForm = (props) => {
+  const { t, adminMarkDownContainer } = props;
+  const {
+    xssOption, tagWhitelist, attrWhitelist, retrieveError,
+  } = adminMarkDownContainer.state;
+
+  const {
+    register, handleSubmit, reset, setValue,
+  } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      tagWhitelist,
+      attrWhitelist,
+    });
+  }, [reset, tagWhitelist, attrWhitelist]);
+
+  const onClickSubmit = useCallback(async(data) => {
     try {
-      await this.props.adminMarkDownContainer.updateXssSetting();
+      await adminMarkDownContainer.setState({ tagWhitelist: data.tagWhitelist ?? '' });
+      await adminMarkDownContainer.setState({ attrWhitelist: data.attrWhitelist ?? '' });
+      await adminMarkDownContainer.updateXssSetting();
       toastSuccess(t('toaster.update_successed', { target: t('markdown_settings.xss_header'), ns: 'commons' }));
     }
     catch (err) {
       toastError(err);
       logger.error(err);
     }
-  }
+  }, [adminMarkDownContainer, t]);
 
-  xssOptions() {
-    const { t, adminMarkDownContainer } = this.props;
-    const { xssOption } = adminMarkDownContainer.state;
+  const xssOptions = useCallback(() => {
 
     const rehypeRecommendedTags = recommendedTagNames.join(',');
     const rehypeRecommendedAttributes = JSON.stringify(recommendedAttributes);
@@ -102,20 +111,19 @@ class XssForm extends React.Component {
               />
               <label className="form-label form-check-label w-100" htmlFor="xssOption2">
                 <p className="fw-bold">{t('markdown_settings.xss_options.custom_whitelist')}</p>
-                <WhitelistInput adminMarkDownContainer={adminMarkDownContainer} />
+                <WhitelistInput adminMarkDownContainer={adminMarkDownContainer} register={register} setValue={setValue} />
               </label>
             </div>
           </div>
         </div>
       </div>
     );
-  }
+  }, [t, adminMarkDownContainer, xssOption, register, setValue]);
 
-  render() {
-    const { t, adminMarkDownContainer } = this.props;
-    const { isEnabledXss } = adminMarkDownContainer.state;
+  const { isEnabledXss } = adminMarkDownContainer.state;
 
-    return (
+  return (
+    <form onSubmit={handleSubmit(onClickSubmit)}>
       <React.Fragment>
         <fieldset className="col-12">
           <div>
@@ -137,16 +145,17 @@ class XssForm extends React.Component {
           </div>
 
           <div className="col-12">
-            {isEnabledXss && this.xssOptions()}
+            {isEnabledXss && xssOptions()}
           </div>
         </fieldset>
-        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminMarkDownContainer.state.retrieveError != null} />
+        <AdminUpdateButtonRow
+          disabled={retrieveError != null}
+          onClick={handleSubmit(onClickSubmit)}
+        />
       </React.Fragment>
-    );
-  }
-
-}
-
+    </form>
+  );
+};
 
 XssForm.propTypes = {
   t: PropTypes.func.isRequired, // i18next

+ 38 - 39
apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.jsx

@@ -1,9 +1,10 @@
 /* eslint-disable react/no-danger */
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 import urljoin from 'url-join';
 
 
@@ -14,18 +15,29 @@ import { useSiteUrl } from '~/stores-universal/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-class GitHubSecurityManagementContents extends React.Component {
+const GitHubSecurityManagementContents = (props) => {
+  const {
+    t, adminGeneralSecurityContainer, adminGitHubSecurityContainer, siteUrl,
+  } = props;
 
-  constructor(props) {
-    super(props);
+  const { isGitHubEnabled } = adminGeneralSecurityContainer.state;
+  const { githubClientId, githubClientSecret, retrieveError } = adminGitHubSecurityContainer.state;
+  const gitHubCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/github/callback');
 
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
+  const { register, handleSubmit, reset } = useForm();
 
-  async onClickSubmit() {
-    const { t, adminGitHubSecurityContainer, adminGeneralSecurityContainer } = this.props;
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      githubClientId,
+      githubClientSecret,
+    });
+  }, [reset, githubClientId, githubClientSecret]);
 
+  const onClickSubmit = useCallback(async(data) => {
     try {
+      await adminGitHubSecurityContainer.changeGitHubClientId(data.githubClientId ?? '');
+      await adminGitHubSecurityContainer.changeGitHubClientSecret(data.githubClientSecret ?? '');
       await adminGitHubSecurityContainer.updateGitHubSetting();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.OAuth.GitHub.updated_github'));
@@ -33,26 +45,19 @@ class GitHubSecurityManagementContents extends React.Component {
     catch (err) {
       toastError(err);
     }
-  }
-
-  render() {
-    const {
-      t, adminGeneralSecurityContainer, adminGitHubSecurityContainer, siteUrl,
-    } = this.props;
-    const { isGitHubEnabled } = adminGeneralSecurityContainer.state;
-    const gitHubCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/github/callback');
-
-    return (
+  }, [adminGitHubSecurityContainer, adminGeneralSecurityContainer, t]);
 
+  return (
+    <form onSubmit={handleSubmit(onClickSubmit)}>
       <React.Fragment>
 
         <h2 className="alert-anchor border-bottom">
           {t('security_settings.OAuth.GitHub.name')}
         </h2>
 
-        {adminGitHubSecurityContainer.state.retrieveError != null && (
+        {retrieveError != null && (
           <div className="alert alert-danger">
-            <p>{t('Error occurred')} : {adminGitHubSecurityContainer.state.retrieveError}</p>
+            <p>{t('Error occurred')} : {retrieveError}</p>
           </div>
         )}
 
@@ -108,9 +113,7 @@ class GitHubSecurityManagementContents extends React.Component {
                 <input
                   className="form-control"
                   type="text"
-                  name="githubClientId"
-                  value={adminGitHubSecurityContainer.state.githubClientId || ''}
-                  onChange={e => adminGitHubSecurityContainer.changeGitHubClientId(e.target.value)}
+                  {...register('githubClientId')}
                 />
                 <p className="form-text text-muted">
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_ID' }) }} />
@@ -124,9 +127,7 @@ class GitHubSecurityManagementContents extends React.Component {
                 <input
                   className="form-control"
                   type="text"
-                  name="githubClientSecret"
-                  value={adminGitHubSecurityContainer.state.githubClientSecret || ''}
-                  onChange={e => adminGitHubSecurityContainer.changeGitHubClientSecret(e.target.value)}
+                  {...register('githubClientSecret')}
                 />
                 <p className="form-text text-muted">
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_SECRET' }) }} />
@@ -158,9 +159,9 @@ class GitHubSecurityManagementContents extends React.Component {
 
             <div className="row mb-4">
               <div className="offset-3 col-5">
-                <div className="btn btn-primary" disabled={adminGitHubSecurityContainer.state.retrieveError != null} onClick={this.onClickSubmit}>
+                <button type="submit" className="btn btn-primary" disabled={retrieveError != null}>
                   {t('Update')}
-                </div>
+                </button>
               </div>
             </div>
 
@@ -185,12 +186,16 @@ class GitHubSecurityManagementContents extends React.Component {
         </div>
 
       </React.Fragment>
+    </form>
+  );
+};
 
-
-    );
-  }
-
-}
+GitHubSecurityManagementContents.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
+  adminGitHubSecurityContainer: PropTypes.instanceOf(AdminGitHubSecurityContainer).isRequired,
+  siteUrl: PropTypes.string,
+};
 
 const GitHubSecurityManagementContentsFC = (props) => {
   const { t } = useTranslation('admin');
@@ -206,10 +211,4 @@ const GitHubSecurityManagementContentsWrapper = withUnstatedContainers(GitHubSec
   AdminGitHubSecurityContainer,
 ]);
 
-GitHubSecurityManagementContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminGitHubSecurityContainer: PropTypes.instanceOf(AdminGitHubSecurityContainer).isRequired,
-};
-
 export default GitHubSecurityManagementContentsWrapper;

+ 36 - 44
apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.jsx

@@ -1,8 +1,9 @@
-import React from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
+import { useForm } from 'react-hook-form';
 import urljoin from 'url-join';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
@@ -12,18 +13,29 @@ import { useSiteUrl } from '~/stores-universal/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-class GoogleSecurityManagementContents extends React.Component {
+const GoogleSecurityManagementContents = (props) => {
+  const {
+    t, adminGeneralSecurityContainer, adminGoogleSecurityContainer, siteUrl,
+  } = props;
 
-  constructor(props) {
-    super(props);
+  const { isGoogleEnabled } = adminGeneralSecurityContainer.state;
+  const { googleClientId, googleClientSecret, retrieveError } = adminGoogleSecurityContainer.state;
+  const googleCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/google/callback');
 
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
+  const { register, handleSubmit, reset } = useForm();
 
-  async onClickSubmit() {
-    const { t, adminGoogleSecurityContainer, adminGeneralSecurityContainer } = this.props;
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      googleClientId,
+      googleClientSecret,
+    });
+  }, [reset, googleClientId, googleClientSecret]);
 
+  const onClickSubmit = useCallback(async(data) => {
     try {
+      await adminGoogleSecurityContainer.changeGoogleClientId(data.googleClientId ?? '');
+      await adminGoogleSecurityContainer.changeGoogleClientSecret(data.googleClientSecret ?? '');
       await adminGoogleSecurityContainer.updateGoogleSetting();
       await adminGeneralSecurityContainer.retrieveSetupStratedies();
       toastSuccess(t('security_settings.OAuth.Google.updated_google'));
@@ -31,26 +43,19 @@ class GoogleSecurityManagementContents extends React.Component {
     catch (err) {
       toastError(err);
     }
-  }
-
-  render() {
-    const {
-      t, adminGeneralSecurityContainer, adminGoogleSecurityContainer, siteUrl,
-    } = this.props;
-    const { isGoogleEnabled } = adminGeneralSecurityContainer.state;
-    const googleCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/google/callback');
-
-    return (
+  }, [adminGoogleSecurityContainer, adminGeneralSecurityContainer, t]);
 
+  return (
+    <form onSubmit={handleSubmit(onClickSubmit)}>
       <React.Fragment>
 
         <h2 className="alert-anchor border-bottom">
           {t('security_settings.OAuth.Google.name')}
         </h2>
 
-        {adminGoogleSecurityContainer.state.retrieveError != null && (
+        {retrieveError != null && (
           <div className="alert alert-danger">
-            <p>{t('Error occurred')} : {adminGoogleSecurityContainer.state.retrieveError}</p>
+            <p>{t('Error occurred')} : {retrieveError}</p>
           </div>
         )}
 
@@ -107,9 +112,7 @@ class GoogleSecurityManagementContents extends React.Component {
                 <input
                   className="form-control"
                   type="text"
-                  name="googleClientId"
-                  value={adminGoogleSecurityContainer.state.googleClientId || ''}
-                  onChange={e => adminGoogleSecurityContainer.changeGoogleClientId(e.target.value)}
+                  {...register('googleClientId')}
                 />
                 <p className="form-text text-muted">
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_ID' }) }} />
@@ -123,9 +126,7 @@ class GoogleSecurityManagementContents extends React.Component {
                 <input
                   className="form-control"
                   type="password"
-                  name="googleClientSecret"
-                  value={adminGoogleSecurityContainer.state.googleClientSecret || ''}
-                  onChange={e => adminGoogleSecurityContainer.changeGoogleClientSecret(e.target.value)}
+                  {...register('googleClientSecret')}
                 />
                 <p className="form-text text-muted">
                   <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_SECRET' }) }} />
@@ -157,12 +158,7 @@ class GoogleSecurityManagementContents extends React.Component {
 
             <div className="row mb-4">
               <div className="offset-3 col-5">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminGoogleSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
+                <button type="submit" className="btn btn-primary" disabled={retrieveError != null}>
                   {t('Update')}
                 </button>
               </div>
@@ -191,20 +187,10 @@ class GoogleSecurityManagementContents extends React.Component {
         </div>
 
       </React.Fragment>
-
-
-    );
-  }
-
-}
-
-const GoogleSecurityManagementContentsFc = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
-  return <GoogleSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
+    </form>
+  );
 };
 
-
 GoogleSecurityManagementContents.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
@@ -212,6 +198,12 @@ GoogleSecurityManagementContents.propTypes = {
   siteUrl: PropTypes.string,
 };
 
+const GoogleSecurityManagementContentsFc = (props) => {
+  const { t } = useTranslation('admin');
+  const { data: siteUrl } = useSiteUrl();
+  return <GoogleSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
+};
+
 const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContentsFc, [
   AdminGeneralSecurityContainer,
   AdminGoogleSecurityContainer,

+ 0 - 450
apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.jsx

@@ -1,450 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import LdapAuthTestModal from './LdapAuthTestModal';
-
-
-class LdapSecuritySettingContents extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isLdapAuthTestModalShown: false,
-    };
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-    this.openLdapAuthTestModal = this.openLdapAuthTestModal.bind(this);
-    this.closeLdapAuthTestModal = this.closeLdapAuthTestModal.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminLdapSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminLdapSecurityContainer.updateLdapSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_settings.ldap.updated_ldap'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  openLdapAuthTestModal() {
-    this.setState({ isLdapAuthTestModalShown: true });
-  }
-
-  closeLdapAuthTestModal() {
-    this.setState({ isLdapAuthTestModalShown: false });
-  }
-
-  render() {
-    const { t, adminGeneralSecurityContainer, adminLdapSecurityContainer } = this.props;
-    const { isLdapEnabled } = adminGeneralSecurityContainer.state;
-
-    return (
-      <React.Fragment>
-
-        <h2 className="alert-anchor border-bottom mb-4">
-          LDAP
-        </h2>
-
-        <div className="row my-4">
-          <div className="col-6 offset-3">
-            <div className="form-check form-switch form-check-success">
-              <input
-                id="isLdapEnabled"
-                className="form-check-input"
-                type="checkbox"
-                checked={isLdapEnabled}
-                onChange={() => { adminGeneralSecurityContainer.switchIsLdapEnabled() }}
-              />
-              <label className="form-label form-check-label" htmlFor="isLdapEnabled">
-                {t('security_settings.ldap.enable_ldap')}
-              </label>
-            </div>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('ldap') && isLdapEnabled)
-              && <div className="badge text-bg-warning">{t('security_settings.setup_is_not_yet_complete')}</div>}
-          </div>
-        </div>
-
-
-        {isLdapEnabled && (
-          <React.Fragment>
-
-            <h3 className="border-bottom mb-4">{t('security_settings.configuration')}</h3>
-
-            <div className="row my-3">
-              <label htmlFor="serverUrl" className="text-start text-md-end col-md-3 col-form-label">
-                Server URL
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="serverUrl"
-                  value={adminLdapSecurityContainer.state.serverUrl || ''}
-                  onChange={e => adminLdapSecurityContainer.changeServerUrl(e.target.value)}
-                />
-                <small>
-                  <p
-                    className="form-text text-muted"
-                    // eslint-disable-next-line react/no-danger
-                    dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.server_url_detail') }}
-                  />
-                  {t('security_settings.example')}: <code>ldaps://ldap.company.com/ou=people,dc=company,dc=com</code>
-                </small>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong>{t('security_settings.ldap.bind_mode')}</strong>
-              </label>
-              <div className="col-md-9">
-                <div className="dropdown">
-                  <button
-                    className="btn btn-outline-secondary dropdown-toggle"
-                    type="button"
-                    id="dropdownMenuButton"
-                    data-bs-toggle="dropdown"
-                    aria-haspopup="true"
-                    aria-expanded="true"
-                  >
-                    {adminLdapSecurityContainer.state.isUserBind
-                      ? <span className="pull-left">{t('security_settings.ldap.bind_user')}</span>
-                      : <span className="pull-left">{t('security_settings.ldap.bind_manager')}</span>}
-                  </button>
-                  <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                    <button className="dropdown-item" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(true) }}>
-                      {t('security_settings.ldap.bind_user')}
-                    </button>
-                    <button className="dropdown-item" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(false) }}>
-                      {t('security_settings.ldap.bind_manager')}
-                    </button>
-                  </div>
-                </div>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong>Bind DN</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="bindDN"
-                  value={adminLdapSecurityContainer.state.ldapBindDN || ''}
-                  onChange={e => adminLdapSecurityContainer.changeBindDN(e.target.value)}
-                />
-                {(adminLdapSecurityContainer.state.isUserBind === true) ? (
-                  <p className="form-text text-muted passport-ldap-userbind">
-                    <small>
-                      {t('security_settings.ldap.bind_DN_user_detail1')}<br />
-                      {/* eslint-disable-next-line react/no-danger */}
-                      <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.bind_DN_user_detail2') }} /><br />
-                      {t('security_settings.example')}1: <code>uid={'{{ username }}'},dc=domain,dc=com</code><br />
-                      {t('security_settings.example')}2: <code>{'{{ username }}'}@domain.com</code>
-                    </small>
-                  </p>
-                )
-                  : (
-                    <p className="form-text text-muted passport-ldap-managerbind">
-                      <small>
-                        {t('security_settings.ldap.bind_DN_manager_detail')}<br />
-                        {t('security_settings.example')}1: <code>uid=admin,dc=domain,dc=com</code><br />
-                        {t('security_settings.example')}2: <code>admin@domain.com</code>
-                      </small>
-                    </p>
-                  )}
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <div htmlFor="bindDNPassword" className="text-start text-md-end col-md-3 col-form-label">
-                <strong>{t('security_settings.ldap.bind_DN_password')}</strong>
-              </div>
-              <div className="col-md-9">
-                {(adminLdapSecurityContainer.state.isUserBind) ? (
-                  <p className="card custom-card passport-ldap-userbind">
-                    <small>
-                      {t('security_settings.ldap.bind_DN_password_user_detail')}
-                    </small>
-                  </p>
-                )
-                  : (
-                    <>
-                      <p className="card custom-card passport-ldap-managerbind">
-                        <small>
-                          {t('security_settings.ldap.bind_DN_password_manager_detail')}
-                        </small>
-                      </p>
-                      <input
-                        className="form-control passport-ldap-managerbind"
-                        type="password"
-                        name="bindDNPassword"
-                        value={adminLdapSecurityContainer.state.ldapBindDNPassword || ''}
-                        onChange={e => adminLdapSecurityContainer.changeBindDNPassword(e.target.value)}
-                      />
-                    </>
-                  )}
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong>{t('security_settings.ldap.search_filter')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="searchFilter"
-                  value={adminLdapSecurityContainer.state.ldapSearchFilter || ''}
-                  onChange={e => adminLdapSecurityContainer.changeSearchFilter(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_settings.ldap.search_filter_detail1')}<br />
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.search_filter_detail2') }} /><br />
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.search_filter_detail3') }} />
-                  </small>
-                </p>
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_settings.example')}1 - {t('security_settings.ldap.search_filter_example1')}:
-                    <code>(|(uid={'{{username}}'})(mail={'{{username}}'}))</code><br />
-                    {t('security_settings.example')}2 - {t('security_settings.ldap.search_filter_example2')}:
-                    <code>(sAMAccountName={'{{username}}'})</code>
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <h3 className="alert-anchor border-bottom mb-4">
-              Attribute Mapping ({t('optional')})
-            </h3>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="attrMapUsername">{t('username')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  placeholder="Default: uid"
-                  name="attrMapUsername"
-                  value={adminLdapSecurityContainer.state.ldapAttrMapUsername || ''}
-                  onChange={e => adminLdapSecurityContainer.changeAttrMapUsername(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.username_detail') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <div className="offset-md-3 col-md-9">
-                <div className="form-check form-check-success">
-                  <input
-                    type="checkbox"
-                    className="form-check-input"
-                    id="isSameUsernameTreatedAsIdenticalUser"
-                    checked={adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
-                    onChange={() => { adminLdapSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
-                  />
-                  <label
-                    className="form-check-label"
-                    htmlFor="isSameUsernameTreatedAsIdenticalUser"
-                    // eslint-disable-next-line react/no-danger
-                    dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical') }}
-                  />
-                </div>
-                <p className="form-text text-muted">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical_warn') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="attrMapMail">{t('Email')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  placeholder="Default: mail"
-                  name="attrMapMail"
-                  value={adminLdapSecurityContainer.state.ldapAttrMapMail || ''}
-                  onChange={e => adminLdapSecurityContainer.changeAttrMapMail(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_settings.ldap.mail_detail')}
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="attrMapName">{t('Name')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="attrMapName"
-                  value={adminLdapSecurityContainer.state.ldapAttrMapName || ''}
-                  onChange={e => adminLdapSecurityContainer.changeAttrMapName(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_settings.ldap.name_detail')}
-                  </small>
-                </p>
-              </div>
-            </div>
-
-
-            <h3 className="alert-anchor border-bottom mb-4">
-              {t('security_settings.ldap.group_search_filter')} ({t('optional')})
-            </h3>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="groupSearchBase">{t('security_settings.ldap.group_search_base_DN')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="groupSearchBase"
-                  value={adminLdapSecurityContainer.state.ldapGroupSearchBase || ''}
-                  onChange={e => adminLdapSecurityContainer.changeGroupSearchBase(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_base_DN_detail') }} /><br />
-                    {t('security_settings.example')}: <code>ou=groups,dc=domain,dc=com</code>
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="groupSearchFilter">{t('security_settings.ldap.group_search_filter')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="groupSearchFilter"
-                  value={adminLdapSecurityContainer.state.ldapGroupSearchFilter || ''}
-                  onChange={e => adminLdapSecurityContainer.changeGroupSearchFilter(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small>
-                    {/* eslint-disable react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail1') }} /><br />
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail2') }} /><br />
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail3') }} />
-                    {/* eslint-enable react/no-danger */}
-                  </small>
-                </p>
-                <p className="form-text text-muted">
-                  <small>
-                    {t('security_settings.example')}:
-                    {/* eslint-disable-next-line react/no-danger */}
-                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail4') }} />
-                  </small>
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">
-                <strong htmlFor="groupDnProperty">{t('security_settings.ldap.group_search_user_DN_property')}</strong>
-              </label>
-              <div className="col-md-9">
-                <input
-                  className="form-control"
-                  type="text"
-                  placeholder="Default: uid"
-                  name="groupDnProperty"
-                  value={adminLdapSecurityContainer.state.ldapGroupDnProperty || ''}
-                  onChange={e => adminLdapSecurityContainer.changeGroupDnProperty(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_user_DN_property_detail') }} />
-                </p>
-              </div>
-            </div>
-            <div className="row my-3">
-              <div className="offset-3 col-5">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminLdapSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
-                  {t('Update')}
-                </button>
-                <button
-                  type="button"
-                  className="btn btn-outline-secondary ms-2"
-                  onClick={this.openLdapAuthTestModal}
-                >{t('security_settings.ldap.test_config')}
-                </button>
-              </div>
-            </div>
-
-          </React.Fragment>
-        )}
-
-
-        <LdapAuthTestModal isOpen={this.state.isLdapAuthTestModalShown} onClose={this.closeLdapAuthTestModal} />
-
-      </React.Fragment>
-    );
-  }
-
-}
-
-LdapSecuritySettingContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
-};
-
-const LdapSecuritySettingContentsWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  return <LdapSecuritySettingContents t={t} {...props} />;
-};
-
-const LdapSecuritySettingContentsWrapper = withUnstatedContainers(LdapSecuritySettingContentsWrapperFC, [
-  AdminGeneralSecurityContainer,
-  AdminLdapSecurityContainer,
-]);
-
-export default LdapSecuritySettingContentsWrapper;

+ 446 - 0
apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx

@@ -0,0 +1,446 @@
+import React, {
+  useState, useEffect, useCallback,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
+
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import LdapAuthTestModal from './LdapAuthTestModal';
+
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  adminLdapSecurityContainer: AdminLdapSecurityContainer;
+};
+
+const LdapSecuritySettingContents = (props: Props) => {
+  const { adminGeneralSecurityContainer, adminLdapSecurityContainer } = props;
+
+  const { t } = useTranslation('admin');
+
+  const { isLdapEnabled } = adminGeneralSecurityContainer.state;
+  const {
+    serverUrl, ldapBindDN, ldapBindDNPassword, ldapSearchFilter,
+    ldapAttrMapUsername, ldapAttrMapMail, ldapAttrMapName,
+    ldapGroupSearchBase, ldapGroupSearchFilter, ldapGroupDnProperty,
+  } = adminLdapSecurityContainer.state;
+
+  const [isLdapAuthTestModalShown, setIsLdapAuthTestModalShown] = useState(false);
+
+  const { register, handleSubmit, reset } = useForm();
+
+  useEffect(() => {
+    reset({
+      serverUrl,
+      ldapBindDN,
+      ldapBindDNPassword,
+      ldapSearchFilter,
+      ldapAttrMapUsername,
+      ldapAttrMapMail,
+      ldapAttrMapName,
+      ldapGroupSearchBase,
+      ldapGroupSearchFilter,
+      ldapGroupDnProperty,
+    });
+  }, [
+    reset, serverUrl, ldapBindDN, ldapBindDNPassword, ldapSearchFilter,
+    ldapAttrMapUsername, ldapAttrMapMail, ldapAttrMapName,
+    ldapGroupSearchBase, ldapGroupSearchFilter, ldapGroupDnProperty,
+  ]);
+
+  const onSubmit = useCallback(async(data) => {
+    try {
+      await adminLdapSecurityContainer.changeServerUrl(data.serverUrl);
+      await adminLdapSecurityContainer.changeBindDN(data.ldapBindDN);
+      await adminLdapSecurityContainer.changeBindDNPassword(data.ldapBindDNPassword);
+      await adminLdapSecurityContainer.changeSearchFilter(data.ldapSearchFilter);
+      await adminLdapSecurityContainer.changeAttrMapUsername(data.ldapAttrMapUsername);
+      await adminLdapSecurityContainer.changeAttrMapMail(data.ldapAttrMapMail);
+      await adminLdapSecurityContainer.changeAttrMapName(data.ldapAttrMapName);
+      await adminLdapSecurityContainer.changeGroupSearchBase(data.ldapGroupSearchBase);
+      await adminLdapSecurityContainer.changeGroupSearchFilter(data.ldapGroupSearchFilter);
+      await adminLdapSecurityContainer.changeGroupDnProperty(data.ldapGroupDnProperty);
+      await adminLdapSecurityContainer.updateLdapSetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_settings.ldap.updated_ldap'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, adminLdapSecurityContainer, adminGeneralSecurityContainer]);
+
+  const openLdapAuthTestModal = useCallback(() => {
+    setIsLdapAuthTestModalShown(true);
+  }, []);
+
+  const closeLdapAuthTestModal = useCallback(() => {
+    setIsLdapAuthTestModalShown(false);
+  }, []);
+
+  return (
+    <React.Fragment>
+
+      <h2 className="alert-anchor border-bottom mb-4">
+        LDAP
+      </h2>
+
+      <div className="row my-4">
+        <div className="col-6 offset-3">
+          <div className="form-check form-switch form-check-success">
+            <input
+              id="isLdapEnabled"
+              className="form-check-input"
+              type="checkbox"
+              checked={isLdapEnabled}
+              onChange={() => { adminGeneralSecurityContainer.switchIsLdapEnabled() }}
+            />
+            <label className="form-label form-check-label" htmlFor="isLdapEnabled">
+              {t('security_settings.ldap.enable_ldap')}
+            </label>
+          </div>
+          {(!adminGeneralSecurityContainer.state.setupStrategies.includes('ldap') && isLdapEnabled)
+              && <div className="badge text-bg-warning">{t('security_settings.setup_is_not_yet_complete')}</div>}
+        </div>
+      </div>
+
+
+      {isLdapEnabled && (
+        <form onSubmit={handleSubmit(onSubmit)}>
+
+          <h3 className="border-bottom mb-4">{t('security_settings.configuration')}</h3>
+
+          <div className="row my-3">
+            <label htmlFor="serverUrl" className="text-start text-md-end col-md-3 col-form-label">
+              Server URL
+            </label>
+            <div className="col-md-9">
+              <input
+                className="form-control"
+                type="text"
+                {...register('serverUrl')}
+              />
+              <small>
+                <p
+                  className="form-text text-muted"
+                  // eslint-disable-next-line react/no-danger
+                  dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.server_url_detail') }}
+                />
+                {t('security_settings.example')}: <code>ldaps://ldap.company.com/ou=people,dc=company,dc=com</code>
+              </small>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <label className="form-label text-start text-md-end col-md-3 col-form-label">
+              <strong>{t('security_settings.ldap.bind_mode')}</strong>
+            </label>
+            <div className="col-md-9">
+              <div className="dropdown">
+                <button
+                  className="btn btn-outline-secondary dropdown-toggle"
+                  type="button"
+                  id="dropdownMenuButton"
+                  data-bs-toggle="dropdown"
+                  aria-haspopup="true"
+                  aria-expanded="true"
+                >
+                  {adminLdapSecurityContainer.state.isUserBind
+                    ? <span className="pull-left">{t('security_settings.ldap.bind_user')}</span>
+                    : <span className="pull-left">{t('security_settings.ldap.bind_manager')}</span>}
+                </button>
+                <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                  <button className="dropdown-item" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(true) }}>
+                    {t('security_settings.ldap.bind_user')}
+                  </button>
+                  <button className="dropdown-item" type="button" onClick={() => { adminLdapSecurityContainer.changeLdapBindMode(false) }}>
+                    {t('security_settings.ldap.bind_manager')}
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <label className="form-label text-start text-md-end col-md-3 col-form-label">
+              <strong>Bind DN</strong>
+            </label>
+            <div className="col-md-9">
+              <input
+                className="form-control"
+                type="text"
+                {...register('ldapBindDN')}
+              />
+              {(adminLdapSecurityContainer.state.isUserBind === true) ? (
+                <p className="form-text text-muted">
+                  <small>
+                    {t('security_settings.ldap.bind_DN_user_detail1')}<br />
+                    {/* eslint-disable-next-line react/no-danger */}
+                    <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.bind_DN_user_detail2') }} /><br />
+                    {t('security_settings.example')}1: <code>uid={'{{ username }}'},dc=domain,dc=com</code><br />
+                    {t('security_settings.example')}2: <code>{'{{ username }}'}@domain.com</code>
+                  </small>
+                </p>
+              )
+                : (
+                  <p className="form-text text-muted">
+                    <small>
+                      {t('security_settings.ldap.bind_DN_manager_detail')}<br />
+                      {t('security_settings.example')}1: <code>uid=admin,dc=domain,dc=com</code><br />
+                      {t('security_settings.example')}2: <code>admin@domain.com</code>
+                    </small>
+                  </p>
+                )}
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <label className="text-start text-md-end col-md-3 col-form-label" htmlFor="bindDNPassword">
+              <strong>{t('security_settings.ldap.bind_DN_password')}</strong>
+            </label>
+            <div className="col-md-9">
+              {(adminLdapSecurityContainer.state.isUserBind) ? (
+                <p className="card custom-card">
+                  <small>
+                    {t('security_settings.ldap.bind_DN_password_user_detail')}
+                  </small>
+                </p>
+              )
+                : (
+                  <>
+                    <input
+                      className="form-control"
+                      type="password"
+                      {...register('ldapBindDNPassword')}
+                    />
+                    <p className="form-text text-muted">
+                      <small>{t('security_settings.ldap.bind_DN_password_manager_detail')}</small>
+                    </p>
+                  </>
+                )}
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <label className="form-label text-start text-md-end col-md-3 col-form-label">
+              <strong>{t('security_settings.ldap.search_filter')}</strong>
+            </label>
+            <div className="col-md-9">
+              <input
+                className="form-control"
+                type="text"
+                {...register('ldapSearchFilter')}
+              />
+              <p className="form-text text-muted">
+                <small>
+                  {t('security_settings.ldap.search_filter_detail1')}<br />
+                  {/* eslint-disable-next-line react/no-danger */}
+                  <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.search_filter_detail2') }} /><br />
+                  {/* eslint-disable-next-line react/no-danger */}
+                  <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.search_filter_detail3') }} />
+                </small>
+              </p>
+              <p className="form-text text-muted">
+                <small>
+                  {t('security_settings.example')}1 - {t('security_settings.ldap.search_filter_example1')}:
+                  <code>(|(uid={'{{username}}'})(mail={'{{username}}'}))</code><br />
+                  {t('security_settings.example')}2 - {t('security_settings.ldap.search_filter_example2')}:
+                  <code>(sAMAccountName={'{{username}}'})</code>
+                </small>
+              </p>
+            </div>
+          </div>
+
+          <h3 className="alert-anchor border-bottom mb-4">
+            Attribute Mapping ({t('optional')})
+          </h3>
+
+          <div className="row my-3">
+            <label className="form-label text-start text-md-end col-md-3 col-form-label" htmlFor="attrMapUsername">
+              <strong>{t('username')}</strong>
+            </label>
+            <div className="col-md-9">
+              <input
+                className="form-control"
+                type="text"
+                placeholder="Default: uid"
+                {...register('ldapAttrMapUsername')}
+              />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.username_detail') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <div className="offset-md-3 col-md-9">
+              <div className="form-check form-check-success">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  id="isSameUsernameTreatedAsIdenticalUser"
+                  checked={adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                  onChange={() => { adminLdapSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                />
+                <label
+                  className="form-check-label"
+                  htmlFor="isSameUsernameTreatedAsIdenticalUser"
+                  // eslint-disable-next-line react/no-danger
+                  dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical') }}
+                />
+              </div>
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical_warn') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <label className="form-label text-start text-md-end col-md-3 col-form-label" htmlFor="attrMapMail">
+              <strong>{t('Email')}</strong>
+            </label>
+            <div className="col-md-9">
+              <input
+                className="form-control"
+                type="text"
+                placeholder="Default: mail"
+                {...register('ldapAttrMapMail')}
+              />
+              <p className="form-text text-muted">
+                <small>
+                  {t('security_settings.ldap.mail_detail')}
+                </small>
+              </p>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <label className="form-label text-start text-md-end col-md-3 col-form-label" htmlFor="attrMapName">
+              <strong>{t('Name')}</strong>
+            </label>
+            <div className="col-md-9">
+              <input
+                className="form-control"
+                type="text"
+                {...register('ldapAttrMapName')}
+              />
+              <p className="form-text text-muted">
+                <small>
+                  {t('security_settings.ldap.name_detail')}
+                </small>
+              </p>
+            </div>
+          </div>
+
+
+          <h3 className="alert-anchor border-bottom mb-4">
+            {t('security_settings.ldap.group_search_filter')} ({t('optional')})
+          </h3>
+
+          <div className="row my-3">
+            <label className="form-label text-start text-md-end col-md-3 col-form-label" htmlFor="groupSearchBase">
+              <strong>{t('security_settings.ldap.group_search_base_DN')}</strong>
+            </label>
+            <div className="col-md-9">
+              <input
+                className="form-control"
+                type="text"
+                {...register('ldapGroupSearchBase')}
+              />
+              <p className="form-text text-muted">
+                <small>
+                  {/* eslint-disable-next-line react/no-danger */}
+                  <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_base_DN_detail') }} /><br />
+                  {t('security_settings.example')}: <code>ou=groups,dc=domain,dc=com</code>
+                </small>
+              </p>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <label className="form-label text-start text-md-end col-md-3 col-form-label" htmlFor="groupSearchFilter">
+              <strong>{t('security_settings.ldap.group_search_filter')}</strong>
+            </label>
+            <div className="col-md-9">
+              <input
+                className="form-control"
+                type="text"
+                {...register('ldapGroupSearchFilter')}
+              />
+              <p className="form-text text-muted">
+                <small>
+                  {/* eslint-disable react/no-danger */}
+                  <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail1') }} /><br />
+                  <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail2') }} /><br />
+                  <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail3') }} />
+                  {/* eslint-enable react/no-danger */}
+                </small>
+              </p>
+              <p className="form-text text-muted">
+                <small>
+                  {t('security_settings.example')}:
+                  {/* eslint-disable-next-line react/no-danger */}
+                  <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail4') }} />
+                </small>
+              </p>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <label className="form-label text-start text-md-end col-md-3 col-form-label" htmlFor="groupDnProperty">
+              <strong>{t('security_settings.ldap.group_search_user_DN_property')}</strong>
+            </label>
+            <div className="col-md-9">
+              <input
+                className="form-control"
+                type="text"
+                placeholder="Default: uid"
+                {...register('ldapGroupDnProperty')}
+              />
+              <p className="form-text text-muted">
+                {/* eslint-disable-next-line react/no-danger */}
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_user_DN_property_detail') }} />
+              </p>
+            </div>
+          </div>
+          <div className="row my-3">
+            <div className="offset-3 col-5">
+              <button
+                type="submit"
+                className="btn btn-primary"
+                disabled={adminLdapSecurityContainer.state.retrieveError != null}
+              >
+                {t('Update')}
+              </button>
+              <button
+                type="button"
+                className="btn btn-outline-secondary ms-2"
+                onClick={openLdapAuthTestModal}
+              >{t('security_settings.ldap.test_config')}
+              </button>
+            </div>
+          </div>
+
+        </form>
+      )}
+
+
+      <LdapAuthTestModal isOpen={isLdapAuthTestModalShown} onClose={closeLdapAuthTestModal} />
+
+    </React.Fragment>
+  );
+};
+
+const LdapSecuritySettingContentsWrapper = withUnstatedContainers(LdapSecuritySettingContents, [
+  AdminGeneralSecurityContainer,
+  AdminLdapSecurityContainer,
+]);
+
+export default LdapSecuritySettingContentsWrapper;

+ 0 - 260
apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -1,260 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
-import PropTypes from 'prop-types';
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useIsMailerSetup } from '~/stores-universal/context';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class LocalSecuritySettingContents extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminGeneralSecurityContainer, adminLocalSecurityContainer } = this.props;
-    try {
-      await adminLocalSecurityContainer.updateLocalSecuritySetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_settings.updated_general_security_setting'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const {
-      t,
-      adminGeneralSecurityContainer,
-      adminLocalSecurityContainer,
-      isMailerSetup,
-    } = this.props;
-    const { registrationMode, isPasswordResetEnabled, isEmailAuthenticationEnabled } = adminLocalSecurityContainer.state;
-    const { isLocalEnabled } = adminGeneralSecurityContainer.state;
-
-    return (
-      <>
-        {adminLocalSecurityContainer.state.retrieveError != null && (
-          <div className="alert alert-danger">
-            <p>
-              {t('Error occurred')} : {adminLocalSecurityContainer.state.retrieveError}
-            </p>
-          </div>
-        )}
-        <h2 className="alert-anchor border-bottom">{t('security_settings.Local.name')}</h2>
-
-        {adminLocalSecurityContainer.state.useOnlyEnvVars && (
-          <p
-            className="alert alert-info"
-            // eslint-disable-next-line max-len
-            dangerouslySetInnerHTML={{
-              __html: t('security_settings.Local.note for the only env option', { env: 'LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }),
-            }}
-          />
-        )}
-
-        <div className="row mt-4 mb-5">
-          <div className="col-6 offset-3">
-            <div className="form-check form-switch form-check-success">
-              <input
-                type="checkbox"
-                className="form-check-input"
-                id="isLocalEnabled"
-                checked={isLocalEnabled}
-                onChange={() => adminGeneralSecurityContainer.switchIsLocalEnabled()}
-                disabled={adminLocalSecurityContainer.state.useOnlyEnvVars}
-              />
-              <label className="form-label form-check-label" htmlFor="isLocalEnabled">
-                {t('security_settings.Local.enable_local')}
-              </label>
-            </div>
-            {!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && isLocalEnabled && (
-              <div className="badge bg-warning text-dark">{t('security_settings.setup_is_not_yet_complete')}</div>
-            )}
-          </div>
-        </div>
-
-        {isLocalEnabled && (
-          <>
-            <h3 className="border-bottom">{t('security_settings.configuration')}</h3>
-
-            <div className="row">
-              <div className="col-12 col-md-4 text-start text-md-end py-2">
-                <strong>{t('security_settings.register_limitation')}</strong>
-              </div>
-              <div className="col-12 col-md-8">
-                <div className="dropdown">
-                  <button
-                    className="btn btn-outline-secondary dropdown-toggle"
-                    type="button"
-                    id="dropdownMenuButton"
-                    data-bs-toggle="dropdown"
-                    aria-haspopup="true"
-                    aria-expanded="true"
-                  >
-                    {registrationMode === 'Open' && t('security_settings.registration_mode.open')}
-                    {registrationMode === 'Restricted' && t('security_settings.registration_mode.restricted')}
-                    {registrationMode === 'Closed' && t('security_settings.registration_mode.closed')}
-                  </button>
-                  <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                    <button
-                      className="dropdown-item"
-                      type="button"
-                      onClick={() => {
-                        adminLocalSecurityContainer.changeRegistrationMode('Open');
-                      }}
-                    >
-                      {t('security_settings.registration_mode.open')}
-                    </button>
-                    <button
-                      className="dropdown-item"
-                      type="button"
-                      onClick={() => {
-                        adminLocalSecurityContainer.changeRegistrationMode('Restricted');
-                      }}
-                    >
-                      {t('security_settings.registration_mode.restricted')}
-                    </button>
-                    <button
-                      className="dropdown-item"
-                      type="button"
-                      onClick={() => {
-                        adminLocalSecurityContainer.changeRegistrationMode('Closed');
-                      }}
-                    >
-                      {t('security_settings.registration_mode.closed')}
-                    </button>
-                  </div>
-                </div>
-                <p className="form-text text-muted small">{t('security_settings.register_limitation_desc')}</p>
-              </div>
-            </div>
-            <div className="row">
-              <div className="col-12 col-md-4 text-start text-md-end">
-                <strong dangerouslySetInnerHTML={{ __html: t('security_settings.The whitelist of registration permission E-mail address') }} />
-              </div>
-              <div className="col-12 col-md-8">
-                <textarea
-                  className="form-control"
-                  type="textarea"
-                  name="registrationWhitelist"
-                  value={adminLocalSecurityContainer.state.registrationWhitelist.join('\n')}
-                  onChange={e => adminLocalSecurityContainer.changeRegistrationWhitelist(e.target.value)}
-                />
-                <p className="form-text text-muted small">
-                  {t('security_settings.restrict_emails')}
-                  <br />
-                  {t('security_settings.for_example')}
-                  <code>@growi.org</code>
-                  {t('security_settings.in_this_case')}
-                  <br />
-                  {t('security_settings.insert_single')}
-                </p>
-              </div>
-            </div>
-
-            <div className="row">
-              <label className="col-12 col-md-4 text-start text-md-end  col-form-label">{t('security_settings.Local.password_reset_by_users')}</label>
-              <div className="col-12 col-md-8">
-                <div className="form-check form-switch form-check-success">
-                  <input
-                    type="checkbox"
-                    className="form-check-input"
-                    id="isPasswordResetEnabled"
-                    checked={isPasswordResetEnabled}
-                    onChange={() => adminLocalSecurityContainer.switchIsPasswordResetEnabled()}
-                  />
-                  <label className="form-label form-check-label" htmlFor="isPasswordResetEnabled">
-                    {t('security_settings.Local.enable_password_reset_by_users')}
-                  </label>
-                </div>
-                {!isMailerSetup && (
-                  <div className="alert alert-warning p-2 my-1 small d-inline-block">
-                    <span>{t('commons:alert.password_reset_please_enable_mailer')}</span>
-                    <Link href="/admin/app#mail-settings">
-                      <span className="material-symbols-outlined">link</span> {t('app_setting.mail_settings')}
-                    </Link>
-                  </div>
-                )}
-                <p className="form-text text-muted small">
-                  {t('security_settings.Local.password_reset_desc')}
-                </p>
-              </div>
-            </div>
-
-            <div className="row">
-              <label className="col-12 col-md-4 text-start text-md-end  col-form-label">{t('security_settings.Local.email_authentication')}</label>
-              <div className="col-12 col-md-8">
-                <div className="form-check form-switch form-check-success">
-                  <input
-                    type="checkbox"
-                    className="form-check-input"
-                    id="isEmailAuthenticationEnabled"
-                    checked={isEmailAuthenticationEnabled}
-                    onChange={() => adminLocalSecurityContainer.switchIsEmailAuthenticationEnabled()}
-                  />
-                  <label className="form-label form-check-label" htmlFor="isEmailAuthenticationEnabled">
-                    {t('security_settings.Local.enable_email_authentication')}
-                  </label>
-                </div>
-                {!isMailerSetup && (
-                  <div className="alert alert-warning p-2 my-1 small d-inline-block">
-                    <span>{t('commons:alert.please_enable_mailer')}</span>
-                    <Link href="/admin/app#mail-settings">
-                      <span className="material-symbols-outlined">link</span> {t('app_setting.mail_settings')}
-                    </Link>
-                  </div>
-                )}
-                <p className="form-text text-muted small">
-                  {t('security_settings.Local.enable_email_authentication_desc')}
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <div className="offset-3 col-6">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminLocalSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
-                  {t('Update')}
-                </button>
-              </div>
-            </div>
-          </>
-        )}
-      </>
-    );
-  }
-
-}
-
-LocalSecuritySettingContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer).isRequired,
-};
-
-const LocalSecuritySettingContentsWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: isMailerSetup } = useIsMailerSetup();
-  return <LocalSecuritySettingContents t={t} {...props} isMailerSetup={isMailerSetup ?? false} />;
-};
-
-const LocalSecuritySettingContentsWrapper = withUnstatedContainers(LocalSecuritySettingContentsWrapperFC, [
-  AdminGeneralSecurityContainer,
-  AdminLocalSecurityContainer,
-]);
-
-export default LocalSecuritySettingContentsWrapper;

+ 249 - 0
apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx

@@ -0,0 +1,249 @@
+import React, { useCallback, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+import { useForm } from 'react-hook-form';
+
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useIsMailerSetup } from '~/stores-universal/context';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  adminLocalSecurityContainer: AdminLocalSecurityContainer;
+};
+
+const LocalSecuritySettingContents = (props: Props): JSX.Element => {
+  const {
+    adminGeneralSecurityContainer,
+    adminLocalSecurityContainer,
+  } = props;
+
+  const { t } = useTranslation('admin');
+  const { data: isMailerSetup = false } = useIsMailerSetup();
+
+  const { register, handleSubmit, reset } = useForm();
+
+  const { registrationMode, isPasswordResetEnabled, isEmailAuthenticationEnabled } = adminLocalSecurityContainer.state;
+  const { isLocalEnabled } = adminGeneralSecurityContainer.state;
+
+  useEffect(() => {
+    reset({
+      registrationWhitelist: adminLocalSecurityContainer.state.registrationWhitelist.join('\n'),
+    });
+  }, [reset, adminLocalSecurityContainer.state.registrationWhitelist]);
+
+  const onSubmit = useCallback(async(data) => {
+    try {
+      await adminLocalSecurityContainer.changeRegistrationWhitelist(data.registrationWhitelist);
+      await adminLocalSecurityContainer.updateLocalSecuritySetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_settings.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, adminGeneralSecurityContainer, adminLocalSecurityContainer]);
+
+  return (
+    <>
+      {adminLocalSecurityContainer.state.retrieveError != null && (
+        <div className="alert alert-danger">
+          <p>
+            {t('Error occurred')} : {adminLocalSecurityContainer.state.retrieveError}
+          </p>
+        </div>
+      )}
+      <h2 className="alert-anchor border-bottom">{t('security_settings.Local.name')}</h2>
+
+      {adminLocalSecurityContainer.state.useOnlyEnvVars && (
+        <p
+          className="alert alert-info"
+          // eslint-disable-next-line max-len
+          dangerouslySetInnerHTML={{
+            __html: t('security_settings.Local.note for the only env option', { env: 'LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }),
+          }}
+        />
+      )}
+
+      <div className="row mt-4 mb-5">
+        <div className="col-6 offset-3">
+          <div className="form-check form-switch form-check-success">
+            <input
+              type="checkbox"
+              className="form-check-input"
+              id="isLocalEnabled"
+              checked={isLocalEnabled}
+              onChange={() => adminGeneralSecurityContainer.switchIsLocalEnabled()}
+              disabled={adminLocalSecurityContainer.state.useOnlyEnvVars}
+            />
+            <label className="form-label form-check-label" htmlFor="isLocalEnabled">
+              {t('security_settings.Local.enable_local')}
+            </label>
+          </div>
+          {!adminGeneralSecurityContainer.state.setupStrategies.includes('local') && isLocalEnabled && (
+            <div className="badge bg-warning text-dark">{t('security_settings.setup_is_not_yet_complete')}</div>
+          )}
+        </div>
+      </div>
+
+      {isLocalEnabled && (
+        <form onSubmit={handleSubmit(onSubmit)}>
+          <h3 className="border-bottom">{t('security_settings.configuration')}</h3>
+
+          <div className="row">
+            <div className="col-12 col-md-4 text-start text-md-end py-2">
+              <strong>{t('security_settings.register_limitation')}</strong>
+            </div>
+            <div className="col-12 col-md-8">
+              <div className="dropdown">
+                <button
+                  className="btn btn-outline-secondary dropdown-toggle"
+                  type="button"
+                  id="dropdownMenuButton"
+                  data-bs-toggle="dropdown"
+                  aria-haspopup="true"
+                  aria-expanded="true"
+                >
+                  {registrationMode === 'Open' && t('security_settings.registration_mode.open')}
+                  {registrationMode === 'Restricted' && t('security_settings.registration_mode.restricted')}
+                  {registrationMode === 'Closed' && t('security_settings.registration_mode.closed')}
+                </button>
+                <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                  <button
+                    className="dropdown-item"
+                    type="button"
+                    onClick={() => {
+                      adminLocalSecurityContainer.changeRegistrationMode('Open');
+                    }}
+                  >
+                    {t('security_settings.registration_mode.open')}
+                  </button>
+                  <button
+                    className="dropdown-item"
+                    type="button"
+                    onClick={() => {
+                      adminLocalSecurityContainer.changeRegistrationMode('Restricted');
+                    }}
+                  >
+                    {t('security_settings.registration_mode.restricted')}
+                  </button>
+                  <button
+                    className="dropdown-item"
+                    type="button"
+                    onClick={() => {
+                      adminLocalSecurityContainer.changeRegistrationMode('Closed');
+                    }}
+                  >
+                    {t('security_settings.registration_mode.closed')}
+                  </button>
+                </div>
+              </div>
+              <p className="form-text text-muted small">{t('security_settings.register_limitation_desc')}</p>
+            </div>
+          </div>
+          <div className="row">
+            <div className="col-12 col-md-4 text-start text-md-end">
+              <strong dangerouslySetInnerHTML={{ __html: t('security_settings.The whitelist of registration permission E-mail address') }} />
+            </div>
+            <div className="col-12 col-md-8">
+              <textarea
+                className="form-control"
+                {...register('registrationWhitelist')}
+              />
+              <p className="form-text text-muted small">
+                {t('security_settings.restrict_emails')}
+                <br />
+                {t('security_settings.for_example')}
+                <code>@growi.org</code>
+                {t('security_settings.in_this_case')}
+                <br />
+                {t('security_settings.insert_single')}
+              </p>
+            </div>
+          </div>
+
+          <div className="row">
+            <label className="col-12 col-md-4 text-start text-md-end  col-form-label">{t('security_settings.Local.password_reset_by_users')}</label>
+            <div className="col-12 col-md-8">
+              <div className="form-check form-switch form-check-success">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  id="isPasswordResetEnabled"
+                  checked={isPasswordResetEnabled}
+                  onChange={() => adminLocalSecurityContainer.switchIsPasswordResetEnabled()}
+                />
+                <label className="form-label form-check-label" htmlFor="isPasswordResetEnabled">
+                  {t('security_settings.Local.enable_password_reset_by_users')}
+                </label>
+              </div>
+              {!isMailerSetup && (
+                <div className="alert alert-warning p-2 my-1 small d-inline-block">
+                  <span>{t('commons:alert.password_reset_please_enable_mailer')}</span>
+                  <Link href="/admin/app#mail-settings">
+                    <span className="material-symbols-outlined">link</span> {t('app_setting.mail_settings')}
+                  </Link>
+                </div>
+              )}
+              <p className="form-text text-muted small">
+                {t('security_settings.Local.password_reset_desc')}
+              </p>
+            </div>
+          </div>
+
+          <div className="row">
+            <label className="col-12 col-md-4 text-start text-md-end  col-form-label">{t('security_settings.Local.email_authentication')}</label>
+            <div className="col-12 col-md-8">
+              <div className="form-check form-switch form-check-success">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  id="isEmailAuthenticationEnabled"
+                  checked={isEmailAuthenticationEnabled}
+                  onChange={() => adminLocalSecurityContainer.switchIsEmailAuthenticationEnabled()}
+                />
+                <label className="form-label form-check-label" htmlFor="isEmailAuthenticationEnabled">
+                  {t('security_settings.Local.enable_email_authentication')}
+                </label>
+              </div>
+              {!isMailerSetup && (
+                <div className="alert alert-warning p-2 my-1 small d-inline-block">
+                  <span>{t('commons:alert.please_enable_mailer')}</span>
+                  <Link href="/admin/app#mail-settings">
+                    <span className="material-symbols-outlined">link</span> {t('app_setting.mail_settings')}
+                  </Link>
+                </div>
+              )}
+              <p className="form-text text-muted small">
+                {t('security_settings.Local.enable_email_authentication_desc')}
+              </p>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <div className="offset-3 col-6">
+              <button
+                type="submit"
+                className="btn btn-primary"
+                disabled={adminLocalSecurityContainer.state.retrieveError != null}
+              >
+                {t('Update')}
+              </button>
+            </div>
+          </div>
+        </form>
+      )}
+    </>
+  );
+};
+
+const LocalSecuritySettingContentsWrapper = withUnstatedContainers(LocalSecuritySettingContents, [
+  AdminGeneralSecurityContainer,
+  AdminLocalSecurityContainer,
+]);
+
+export default LocalSecuritySettingContentsWrapper;

+ 0 - 488
apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -1,488 +0,0 @@
-import React from 'react';
-
-import { pathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import urljoin from 'url-join';
-
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class OidcSecurityManagementContents extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminOidcSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminOidcSecurityContainer.updateOidcSetting();
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_settings.OAuth.OIDC.updated_oidc'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const {
-      t, adminGeneralSecurityContainer, adminOidcSecurityContainer, siteUrl,
-    } = this.props;
-    const { isOidcEnabled } = adminGeneralSecurityContainer.state;
-    const oidcCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/oidc/callback');
-
-    return (
-
-      <>
-        <h2 className="alert-anchor border-bottom">
-          {t('security_settings.OAuth.OIDC.name')}
-        </h2>
-
-        <div className="row  my-4">
-          <div className="offset-3 col-6">
-            <div className="form-check form-switch form-check-success">
-              <input
-                id="isOidcEnabled"
-                className="form-check-input"
-                type="checkbox"
-                checked={adminGeneralSecurityContainer.state.isOidcEnabled}
-                onChange={() => { adminGeneralSecurityContainer.switchIsOidcEnabled() }}
-              />
-              <label className="form-label form-check-label" htmlFor="isOidcEnabled">
-                {t('security_settings.OAuth.enable_oidc')}
-              </label>
-            </div>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('oidc') && isOidcEnabled)
-              && <div className="badge text-bg-warning">{t('security_settings.setup_is_not_yet_complete')}</div>}
-          </div>
-        </div>
-
-        <div className="row mb-5">
-          <label className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.callback_URL')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              value={oidcCallbackUrl}
-              readOnly
-            />
-            <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-            {(siteUrl == null || siteUrl === '') && (
-              <div className="alert alert-danger">
-                <span className="material-symbols-outlined">error</span>
-                <span
-                  // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
-                />
-              </div>
-            )}
-          </div>
-        </div>
-
-        {isOidcEnabled && (
-          <>
-
-            <h3 className="border-bottom mb-4">{t('security_settings.configuration')}</h3>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcProviderName" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.providerName')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcProviderName"
-                  value={adminOidcSecurityContainer.state.oidcProviderName || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcProviderName(e.target.value)}
-                />
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcIssuerHost" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.issuerHost')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcIssuerHost"
-                  value={adminOidcSecurityContainer.state.oidcIssuerHost || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcIssuerHost(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_OIDC_ISSUER_HOST' }) }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcClientId" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.clientID')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcClientId"
-                  value={adminOidcSecurityContainer.state.oidcClientId || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcClientId(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_ID' }) }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcClientSecret" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.client_secret')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcClientSecret"
-                  value={adminOidcSecurityContainer.state.oidcClientSecret || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcClientSecret(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_SECRET' }) }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcAuthorizationEndpoint" className="text-start text-md-end col-md-3 col-form-label">
-                {t('security_settings.authorization_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcAuthorizationEndpoint"
-                  value={adminOidcSecurityContainer.state.oidcAuthorizationEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcAuthorizationEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcTokenEndpoint" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.token_endpoint')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcTokenEndpoint"
-                  value={adminOidcSecurityContainer.state.oidcTokenEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcTokenEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcRevocationEndpoint" className="text-start text-md-end col-md-3 col-form-label">
-                {t('security_settings.revocation_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcRevocationEndpoint"
-                  value={adminOidcSecurityContainer.state.oidcRevocationEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcRevocationEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcIntrospectionEndpoint" className="text-start text-md-end col-md-3 col-form-label">
-                {t('security_settings.introspection_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcIntrospectionEndpoint"
-                  value={adminOidcSecurityContainer.state.oidcIntrospectionEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcIntrospectionEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcUserInfoEndpoint" className="text-start text-md-end col-md-3 col-form-label">
-                {t('security_settings.userinfo_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcUserInfoEndpoint"
-                  value={adminOidcSecurityContainer.state.oidcUserInfoEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcUserInfoEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcEndSessionEndpoint" className="text-start text-md-end col-md-3 col-form-label">
-                {t('security_settings.end_session_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcEndSessionEndpoint"
-                  value={adminOidcSecurityContainer.state.oidcEndSessionEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcEndSessionEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcRegistrationEndpoint" className="text-start text-md-end col-md-3 col-form-label">
-                {t('security_settings.registration_endpoint')}
-              </label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcRegistrationEndpoint"
-                  value={adminOidcSecurityContainer.state.oidcRegistrationEndpoint || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcRegistrationEndpoint(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcJWKSUri" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.jwks_uri')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcJWKSUri"
-                  value={adminOidcSecurityContainer.state.oidcJWKSUri || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcJWKSUri(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
-                </p>
-              </div>
-            </div>
-
-            <h3 className="alert-anchor border-bottom mb-4">
-              Attribute Mapping ({t('optional')})
-            </h3>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcAttrMapId" className="text-start text-md-end col-md-3 col-form-label">Identifier</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcAttrMapId"
-                  value={adminOidcSecurityContainer.state.oidcAttrMapId || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapId(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.id_detail') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcAttrMapUserName" className="text-start text-md-end col-md-3 col-form-label">{t('username')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcAttrMapUserName"
-                  value={adminOidcSecurityContainer.state.oidcAttrMapUserName || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapUserName(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.username_detail') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcAttrMapName" className="text-start text-md-end col-md-3 col-form-label">{t('Name')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcAttrMapName"
-                  value={adminOidcSecurityContainer.state.oidcAttrMapName || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapName(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.name_detail') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label htmlFor="oidcAttrMapEmail" className="text-start text-md-end col-md-3 col-form-label">{t('Email')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  name="oidcAttrMapEmail"
-                  value={adminOidcSecurityContainer.state.oidcAttrMapEmail || ''}
-                  onChange={e => adminOidcSecurityContainer.changeOidcAttrMapEmail(e.target.value)}
-                />
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.mapping_detail', { target: t('Email') }) }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <label className="form-label text-start text-md-end col-md-3 col-form-label">{t('security_settings.callback_URL')}</label>
-              <div className="col-md-6">
-                <input
-                  className="form-control"
-                  type="text"
-                  defaultValue={oidcCallbackUrl}
-                  readOnly
-                />
-                <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
-                {(siteUrl == null || siteUrl === '') && (
-                  <div className="alert alert-danger">
-                    <span className="material-symbols-outlined">error</span>
-                    <span
-                      // eslint-disable-next-line max-len
-                      dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
-                    />
-                  </div>
-                )}
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <div className="offset-md-3 col-md-6">
-                <div className="form-check form-check-success">
-                  <input
-                    id="bindByUserName-oidc"
-                    className="form-check-input"
-                    type="checkbox"
-                    checked={adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
-                    onChange={() => { adminOidcSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
-                  />
-                  <label
-                    className="form-label form-check-label"
-                    htmlFor="bindByUserName-oidc"
-                    dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical') }}
-                  />
-                </div>
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical_warn') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row mb-4">
-              <div className="offset-md-3 col-md-6">
-                <div className="form-check form-check-success">
-                  <input
-                    id="bindByEmail-oidc"
-                    className="form-check-input"
-                    type="checkbox"
-                    checked={adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
-                    onChange={() => { adminOidcSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
-                  />
-                  <label
-                    className="form-label form-check-label"
-                    htmlFor="bindByEmail-oidc"
-                    dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical') }}
-                  />
-                </div>
-                <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical_warn') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="row my-3">
-              <div className="offset-3 col-5">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminOidcSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
-                  {t('Update')}
-                </button>
-              </div>
-            </div>
-          </>
-        )}
-
-
-        <hr />
-
-        <div style={{ minHeight: '300px' }}>
-          <h4>
-            <span className="material-symbols-outlined" aria-hidden="true">help</span>
-            <a href="#collapseHelpForOidcOauth" data-bs-toggle="collapse"> {t('security_settings.OAuth.how_to.oidc')}</a>
-          </h4>
-          <div className=" card custom-card bg-body-tertiary">
-            <ol id="collapseHelpForOidcOauth" className="collapse mb-0">
-              <li>{t('security_settings.OAuth.OIDC.register_1')}</li>
-              <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.register_2', { url: oidcCallbackUrl }) }} />
-              <li>{t('security_settings.OAuth.OIDC.register_3')}</li>
-            </ol>
-          </div>
-        </div>
-
-      </>
-    );
-  }
-
-}
-
-OidcSecurityManagementContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer).isRequired,
-  siteUrl: PropTypes.string,
-};
-
-const OidcSecurityManagementContentsWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
-  return <OidcSecurityManagementContents t={t} {...props} siteUrl={siteUrl} />;
-};
-
-const OidcSecurityManagementContentsWrapper = withUnstatedContainers(OidcSecurityManagementContentsWrapperFC, [
-  AdminGeneralSecurityContainer,
-  AdminOidcSecurityContainer,
-]);
-
-export default OidcSecurityManagementContentsWrapper;

+ 491 - 0
apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx

@@ -0,0 +1,491 @@
+import React, { useEffect, useCallback } from 'react';
+
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
+import urljoin from 'url-join';
+
+
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSiteUrl } from '~/stores-universal/context';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  adminOidcSecurityContainer: AdminOidcSecurityContainer;
+};
+
+const OidcSecurityManagementContents = (props: Props) => {
+  const { t } = useTranslation('admin');
+  const { data: siteUrl } = useSiteUrl();
+
+  const {
+    adminGeneralSecurityContainer, adminOidcSecurityContainer,
+  } = props;
+  const { isOidcEnabled } = adminGeneralSecurityContainer.state;
+  const {
+    oidcProviderName, oidcIssuerHost, oidcClientId, oidcClientSecret,
+    oidcAuthorizationEndpoint, oidcTokenEndpoint, oidcRevocationEndpoint, oidcIntrospectionEndpoint,
+    oidcUserInfoEndpoint, oidcEndSessionEndpoint, oidcRegistrationEndpoint, oidcJWKSUri,
+    oidcAttrMapId, oidcAttrMapUserName, oidcAttrMapName, oidcAttrMapEmail,
+  } = adminOidcSecurityContainer.state;
+
+  const oidcCallbackUrl = urljoin(
+    siteUrl == null ? '' : pathUtils.removeTrailingSlash(siteUrl),
+    '/passport/oidc/callback',
+  );
+
+  const { register, handleSubmit, reset } = useForm();
+
+  useEffect(() => {
+    reset({
+      oidcProviderName,
+      oidcIssuerHost,
+      oidcClientId,
+      oidcClientSecret,
+      oidcAuthorizationEndpoint,
+      oidcTokenEndpoint,
+      oidcRevocationEndpoint,
+      oidcIntrospectionEndpoint,
+      oidcUserInfoEndpoint,
+      oidcEndSessionEndpoint,
+      oidcRegistrationEndpoint,
+      oidcJWKSUri,
+      oidcAttrMapId,
+      oidcAttrMapUserName,
+      oidcAttrMapName,
+      oidcAttrMapEmail,
+    });
+  }, [
+    reset, oidcProviderName, oidcIssuerHost, oidcClientId, oidcClientSecret,
+    oidcAuthorizationEndpoint, oidcTokenEndpoint, oidcRevocationEndpoint, oidcIntrospectionEndpoint,
+    oidcUserInfoEndpoint, oidcEndSessionEndpoint, oidcRegistrationEndpoint, oidcJWKSUri,
+    oidcAttrMapId, oidcAttrMapUserName, oidcAttrMapName, oidcAttrMapEmail,
+  ]);
+
+  const onSubmit = useCallback(async(data) => {
+    try {
+      await adminOidcSecurityContainer.changeOidcProviderName(data.oidcProviderName);
+      await adminOidcSecurityContainer.changeOidcIssuerHost(data.oidcIssuerHost);
+      await adminOidcSecurityContainer.changeOidcClientId(data.oidcClientId);
+      await adminOidcSecurityContainer.changeOidcClientSecret(data.oidcClientSecret);
+      await adminOidcSecurityContainer.changeOidcAuthorizationEndpoint(data.oidcAuthorizationEndpoint);
+      await adminOidcSecurityContainer.changeOidcTokenEndpoint(data.oidcTokenEndpoint);
+      await adminOidcSecurityContainer.changeOidcRevocationEndpoint(data.oidcRevocationEndpoint);
+      await adminOidcSecurityContainer.changeOidcIntrospectionEndpoint(data.oidcIntrospectionEndpoint);
+      await adminOidcSecurityContainer.changeOidcUserInfoEndpoint(data.oidcUserInfoEndpoint);
+      await adminOidcSecurityContainer.changeOidcEndSessionEndpoint(data.oidcEndSessionEndpoint);
+      await adminOidcSecurityContainer.changeOidcRegistrationEndpoint(data.oidcRegistrationEndpoint);
+      await adminOidcSecurityContainer.changeOidcJWKSUri(data.oidcJWKSUri);
+      await adminOidcSecurityContainer.changeOidcAttrMapId(data.oidcAttrMapId);
+      await adminOidcSecurityContainer.changeOidcAttrMapUserName(data.oidcAttrMapUserName);
+      await adminOidcSecurityContainer.changeOidcAttrMapName(data.oidcAttrMapName);
+      await adminOidcSecurityContainer.changeOidcAttrMapEmail(data.oidcAttrMapEmail);
+      await adminOidcSecurityContainer.updateOidcSetting();
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+      toastSuccess(t('security_settings.OAuth.OIDC.updated_oidc'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, adminOidcSecurityContainer, adminGeneralSecurityContainer]);
+
+  return (
+    <>
+      <h2 className="alert-anchor border-bottom">
+        {t('security_settings.OAuth.OIDC.name')}
+      </h2>
+
+      <div className="row  my-4">
+        <div className="offset-3 col-6">
+          <div className="form-check form-switch form-check-success">
+            <input
+              id="isOidcEnabled"
+              className="form-check-input"
+              type="checkbox"
+              checked={adminGeneralSecurityContainer.state.isOidcEnabled}
+              onChange={() => { adminGeneralSecurityContainer.switchIsOidcEnabled() }}
+            />
+            <label className="form-label form-check-label" htmlFor="isOidcEnabled">
+              {t('security_settings.OAuth.enable_oidc')}
+            </label>
+          </div>
+          {(!adminGeneralSecurityContainer.state.setupStrategies.includes('oidc') && isOidcEnabled)
+              && <div className="badge text-bg-warning">{t('security_settings.setup_is_not_yet_complete')}</div>}
+        </div>
+      </div>
+
+      <div className="row mb-5">
+        <label className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.callback_URL')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            value={oidcCallbackUrl}
+            readOnly
+          />
+          <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
+          {(siteUrl == null || siteUrl === '') && (
+            <div className="alert alert-danger">
+              <span className="material-symbols-outlined">error</span>
+              <span
+                // eslint-disable-next-line max-len
+                dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
+              />
+            </div>
+          )}
+        </div>
+      </div>
+
+      {isOidcEnabled && (
+        <form onSubmit={handleSubmit(onSubmit)}>
+
+          <h3 className="border-bottom mb-4">{t('security_settings.configuration')}</h3>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcProviderName" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.providerName')}</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcProviderName')}
+              />
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcIssuerHost" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.issuerHost')}</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcIssuerHost')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_OIDC_ISSUER_HOST' }) }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcClientId" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.clientID')}</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcClientId')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_ID' }) }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcClientSecret" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.client_secret')}</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcClientSecret')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_SECRET' }) }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcAuthorizationEndpoint" className="text-start text-md-end col-md-3 col-form-label">
+              {t('security_settings.authorization_endpoint')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcAuthorizationEndpoint')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcTokenEndpoint" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.token_endpoint')}</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcTokenEndpoint')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcRevocationEndpoint" className="text-start text-md-end col-md-3 col-form-label">
+              {t('security_settings.revocation_endpoint')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcRevocationEndpoint')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcIntrospectionEndpoint" className="text-start text-md-end col-md-3 col-form-label">
+              {t('security_settings.introspection_endpoint')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcIntrospectionEndpoint')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcUserInfoEndpoint" className="text-start text-md-end col-md-3 col-form-label">
+              {t('security_settings.userinfo_endpoint')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcUserInfoEndpoint')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcEndSessionEndpoint" className="text-start text-md-end col-md-3 col-form-label">
+              {t('security_settings.end_session_endpoint')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcEndSessionEndpoint')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcRegistrationEndpoint" className="text-start text-md-end col-md-3 col-form-label">
+              {t('security_settings.registration_endpoint')}
+            </label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcRegistrationEndpoint')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcJWKSUri" className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.jwks_uri')}</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcJWKSUri')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+              </p>
+            </div>
+          </div>
+
+          <h3 className="alert-anchor border-bottom mb-4">
+            Attribute Mapping ({t('optional')})
+          </h3>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcAttrMapId" className="text-start text-md-end col-md-3 col-form-label">Identifier</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcAttrMapId')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.id_detail') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcAttrMapUserName" className="text-start text-md-end col-md-3 col-form-label">{t('username')}</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcAttrMapUserName')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.username_detail') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcAttrMapName" className="text-start text-md-end col-md-3 col-form-label">{t('Name')}</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcAttrMapName')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.name_detail') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label htmlFor="oidcAttrMapEmail" className="text-start text-md-end col-md-3 col-form-label">{t('Email')}</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                {...register('oidcAttrMapEmail')}
+              />
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.mapping_detail', { target: t('Email') }) }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <label className="form-label text-start text-md-end col-md-3 col-form-label">{t('security_settings.callback_URL')}</label>
+            <div className="col-md-6">
+              <input
+                className="form-control"
+                type="text"
+                defaultValue={oidcCallbackUrl}
+                readOnly
+              />
+              <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
+              {(siteUrl == null || siteUrl === '') && (
+                <div className="alert alert-danger">
+                  <span className="material-symbols-outlined">error</span>
+                  <span
+                    // eslint-disable-next-line max-len
+                    dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
+                  />
+                </div>
+              )}
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <div className="offset-md-3 col-md-6">
+              <div className="form-check form-check-success">
+                <input
+                  id="bindByUserName-oidc"
+                  className="form-check-input"
+                  type="checkbox"
+                  checked={adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
+                  onChange={() => { adminOidcSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                />
+                <label
+                  className="form-label form-check-label"
+                  htmlFor="bindByUserName-oidc"
+                  dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical') }}
+                />
+              </div>
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical_warn') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row mb-4">
+            <div className="offset-md-3 col-md-6">
+              <div className="form-check form-check-success">
+                <input
+                  id="bindByEmail-oidc"
+                  className="form-check-input"
+                  type="checkbox"
+                  checked={adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
+                  onChange={() => { adminOidcSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                />
+                <label
+                  className="form-label form-check-label"
+                  htmlFor="bindByEmail-oidc"
+                  dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical') }}
+                />
+              </div>
+              <p className="form-text text-muted">
+                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical_warn') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <div className="offset-3 col-5">
+              <button
+                type="submit"
+                className="btn btn-primary"
+                disabled={adminOidcSecurityContainer.state.retrieveError != null}
+              >
+                {t('Update')}
+              </button>
+            </div>
+          </div>
+        </form>
+      )}
+
+
+      <hr />
+
+      <div style={{ minHeight: '300px' }}>
+        <h4>
+          <span className="material-symbols-outlined" aria-hidden="true">help</span>
+          <a href="#collapseHelpForOidcOauth" data-bs-toggle="collapse"> {t('security_settings.OAuth.how_to.oidc')}</a>
+        </h4>
+        <div className=" card custom-card bg-body-tertiary">
+          <ol id="collapseHelpForOidcOauth" className="collapse mb-0">
+            <li>{t('security_settings.OAuth.OIDC.register_1')}</li>
+            <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.register_2', { url: oidcCallbackUrl }) }} />
+            <li>{t('security_settings.OAuth.OIDC.register_3')}</li>
+          </ol>
+        </div>
+      </div>
+
+    </>
+  );
+};
+
+const OidcSecurityManagementContentsWrapper = withUnstatedContainers(OidcSecurityManagementContents, [
+  AdminGeneralSecurityContainer,
+  AdminOidcSecurityContainer,
+]);
+
+export default OidcSecurityManagementContentsWrapper;

+ 0 - 563
apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.jsx

@@ -1,563 +0,0 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
-
-import { pathUtils } from '@growi/core/dist/utils';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { Collapse } from 'reactstrap';
-import urljoin from 'url-join';
-
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
-class SamlSecurityManagementContents extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isHelpOpened: false,
-    };
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminSamlSecurityContainer, adminGeneralSecurityContainer } = this.props;
-
-    try {
-      await adminSamlSecurityContainer.updateSamlSetting();
-      toastSuccess(t('security_settings.SAML.updated_saml'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-    try {
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const {
-      t, adminGeneralSecurityContainer, adminSamlSecurityContainer, siteUrl,
-    } = this.props;
-    const { useOnlyEnvVars } = adminSamlSecurityContainer.state;
-    const { isSamlEnabled } = adminGeneralSecurityContainer.state;
-
-    const samlCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/saml/callback');
-
-    return (
-      <React.Fragment>
-
-        <h2 className="alert-anchor border-bottom">
-          {t('security_settings.SAML.name')}
-        </h2>
-
-        {useOnlyEnvVars && (
-          <p
-            className="alert alert-info"
-            dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.note for the only env option', { env: 'SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
-          />
-        )}
-
-        <div className="row mt-4 mb-5">
-          <div className="col-6 offset-3">
-            <div className="form-check form-switch form-check-success">
-              <input
-                id="isSamlEnabled"
-                className="form-check-input"
-                type="checkbox"
-                checked={adminGeneralSecurityContainer.state.isSamlEnabled}
-                onChange={() => { adminGeneralSecurityContainer.switchIsSamlEnabled() }}
-                disabled={adminSamlSecurityContainer.state.useOnlyEnvVars}
-              />
-              <label className="form-label form-check-label" htmlFor="isSamlEnabled">
-                {t('security_settings.SAML.enable_saml')}
-              </label>
-            </div>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('saml') && isSamlEnabled)
-              && <div className="badge text-bg-warning">{t('security_settings.setup_is_not_yet_complete')}</div>}
-          </div>
-        </div>
-
-        <div className="row mb-5">
-          <label className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.callback_URL')}</label>
-          <div className="col-md-6">
-            <input
-              className="form-control"
-              type="text"
-              defaultValue={samlCallbackUrl}
-              readOnly
-            />
-            <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'SAML Identity' })}</p>
-            {(siteUrl == null || siteUrl === '') && (
-              <div className="alert alert-danger">
-                <span className="material-symbols-outlined">error</span>
-                <span
-                  // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
-                />
-              </div>
-            )}
-          </div>
-        </div>
-
-        {isSamlEnabled && (
-          <React.Fragment>
-
-            {(adminSamlSecurityContainer.state.missingMandatoryConfigKeys.length !== 0) && (
-              <div className="alert alert-danger">
-                {t('security_settings.missing mandatory configs')}
-                <ul className="mb-0">
-                  {adminSamlSecurityContainer.state.missingMandatoryConfigKeys.map((configKey) => {
-                    const key = configKey.replace('security:passport-saml:', '');
-                    return <li key={configKey}>{t(`security_settings.form_item_name.${key}`)}</li>;
-                  })}
-                </ul>
-              </div>
-            )}
-
-
-            <h3 className="alert-anchor border-bottom mb-3">
-              Basic Settings
-            </h3>
-
-            <table className={`table settings-table ${useOnlyEnvVars && 'use-only-env-vars'}`}>
-              <colgroup>
-                <col className="item-name" />
-                <col className="from-db" />
-                <col className="from-env-vars" />
-              </colgroup>
-              <thead>
-                <tr><th></th><th>Database</th><th>Environment variables</th></tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <th>{t('security_settings.form_item_name.entryPoint')}</th>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      name="samlEntryPoint"
-                      readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlEntryPoint}
-                      onChange={e => adminSamlSecurityContainer.changeSamlEntryPoint(e.target.value)}
-                    />
-                  </td>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.envEntryPoint || ''}
-                      readOnly
-                    />
-                    <p className="form-text text-muted">
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ENTRY_POINT' }) }} />
-                    </p>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('security_settings.form_item_name.issuer')}</th>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      name="samlEnvVarissuer"
-                      readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlIssuer}
-                      onChange={e => adminSamlSecurityContainer.changeSamlIssuer(e.target.value)}
-                    />
-                  </td>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.envIssuer || ''}
-                      readOnly
-                    />
-                    <p className="form-text text-muted">
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ISSUER' }) }} />
-                    </p>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('security_settings.form_item_name.cert')}</th>
-                  <td>
-                    <textarea
-                      className="form-control form-control-sm"
-                      type="text"
-                      rows="5"
-                      name="samlCert"
-                      readOnly={useOnlyEnvVars}
-                      value={adminSamlSecurityContainer.state.samlCert}
-                      onChange={e => adminSamlSecurityContainer.changeSamlCert(e.target.value)}
-                    />
-                    <p>
-                      <small>
-                        {t('security_settings.SAML.cert_detail')}
-                      </small>
-                    </p>
-                    <div>
-                      <small>
-                        e.g.
-                        <pre className="card custom-card">{`-----BEGIN CERTIFICATE-----
-MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
-UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
-...
-crmVwBzbloUO2l6k1ibwD2WVwpdxMKIF5z58HfKAvxZAzCHE7kMEZr1ge30WRXQA
-pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
------END CERTIFICATE-----
-                        `}
-                        </pre>
-                      </small>
-                    </div>
-                  </td>
-                  <td>
-                    <textarea
-                      className="form-control form-control-sm"
-                      type="text"
-                      rows="5"
-                      readOnly
-                      value={adminSamlSecurityContainer.state.envCert || ''}
-                    />
-                    <p className="form-text text-muted">
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_CERT' }) }} />
-                    </p>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
-
-            <h3 className="alert-anchor border-bottom mt-5 mb-3">
-              Attribute Mapping
-            </h3>
-
-            <table className="table settings-table">
-              <colgroup>
-                <col className="item-name" />
-                <col className="from-db" />
-                <col className="from-env-vars" />
-              </colgroup>
-              <thead>
-                <tr><th></th><th>Database</th><th>Environment variables</th></tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <th>{t('security_settings.form_item_name.attrMapId')}</th>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.samlAttrMapId}
-                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapId(e.target.value)}
-                    />
-                    <p className="form-text text-muted">
-                      <small>
-                        {t('security_settings.SAML.id_detail')}
-                      </small>
-                    </p>
-                  </td>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.envAttrMapId || ''}
-                      readOnly
-                    />
-                    <p className="form-text text-muted">
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_ID' }) }} />
-                    </p>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('security_settings.form_item_name.attrMapUsername')}</th>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.samlAttrMapUsername}
-                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapUserName(e.target.value)}
-                    />
-                    <p className="form-text text-muted">
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.username_detail') }} />
-                    </p>
-                  </td>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.envAttrMapUsername || ''}
-                      readOnly
-                    />
-                    <p className="form-text text-muted">
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_USERNAME' }) }} />
-                    </p>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('security_settings.form_item_name.attrMapMail')}</th>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.samlAttrMapMail}
-                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapMail(e.target.value)}
-                    />
-                    <p className="form-text text-muted">
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.mapping_detail', { target: 'Email' }) }} />
-                    </p>
-                  </td>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.envAttrMapMail || ''}
-                      readOnly
-                    />
-                    <p className="form-text text-muted">
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_MAIL' }) }} />
-                    </p>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('security_settings.form_item_name.attrMapFirstName')}</th>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.samlAttrMapFirstName}
-                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapFirstName(e.target.value)}
-                    />
-                    <p className="form-text text-muted">
-                      {/* eslint-disable-next-line max-len */}
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.mapping_detail', { target: t('security_settings.form_item_name.attrMapFirstName') }) }} />
-                    </p>
-                  </td>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.envAttrMapFirstName || ''}
-                      readOnly
-                    />
-                    <p className="form-text text-muted">
-                      <small>
-                        <span dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_FIRST_NAME' }) }} />
-                        <br />
-                        <span dangerouslySetInnerHTML={{ __html: t('security_settings.Use default if both are empty', { target: 'firstName' }) }} />
-                      </small>
-                    </p>
-                  </td>
-                </tr>
-                <tr>
-                  <th>{t('security_settings.form_item_name.attrMapLastName')}</th>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.samlAttrMapLastName}
-                      onChange={e => adminSamlSecurityContainer.changeSamlAttrMapLastName(e.target.value)}
-                    />
-                    <p className="form-text text-muted">
-                      {/* eslint-disable-next-line max-len */}
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.mapping_detail', { target: t('security_settings.form_item_name.attrMapLastName') }) }} />
-                    </p>
-                  </td>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.envAttrMapLastName || ''}
-                      readOnly
-                    />
-                    <p className="form-text text-muted">
-                      <small>
-                        <span dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_LAST_NAME' }) }} />
-                        <br />
-                        <span dangerouslySetInnerHTML={{ __html: t('security_settings.Use default if both are empty', { target: 'lastName' }) }} />
-                      </small>
-                    </p>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
-
-            <h3 className="alert-anchor border-bottom mt-5 mb-4">
-              Attribute Mapping Options
-            </h3>
-
-            <div className="row ms-3">
-              <div className="form-check form-check-success">
-                <input
-                  id="bindByUserName-SAML"
-                  className="form-check-input"
-                  type="checkbox"
-                  checked={adminSamlSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
-                  onChange={() => { adminSamlSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
-                />
-                <label
-                  className="form-label form-check-label"
-                  htmlFor="bindByUserName-SAML"
-                  dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical') }}
-                />
-              </div>
-              <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical_warn') }} />
-              </p>
-            </div>
-
-            <div className="row mb-5 ms-3">
-              <div className="form-check form-check-success">
-                <input
-                  id="bindByEmail-SAML"
-                  className="form-check-input"
-                  type="checkbox"
-                  checked={adminSamlSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
-                  onChange={() => { adminSamlSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
-                />
-                <label
-                  className="form-label form-check-label"
-                  htmlFor="bindByEmail-SAML"
-                  dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical') }}
-                />
-              </div>
-              <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical_warn') }} />
-              </p>
-            </div>
-
-            <h3 className="alert-anchor border-bottom mb-4">
-              Attribute-based Login Control
-            </h3>
-
-            <p className="form-text text-muted">
-              <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.attr_based_login_control_detail') }} />
-            </p>
-
-            <table className="table settings-table">
-              <colgroup>
-                <col className="item-name" />
-                <col className="from-db" />
-                <col className="from-env-vars" />
-              </colgroup>
-              <thead>
-                <tr><th></th><th>Database</th><th>Environment variables</th></tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <th>
-                    { t('security_settings.form_item_name.ABLCRule') }
-                  </th>
-                  <td>
-                    <textarea
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.samlABLCRule || ''}
-                      onChange={(e) => { adminSamlSecurityContainer.changeSamlABLCRule(e.target.value) }}
-                    />
-                    <div className="mt-2">
-                      <p>
-                        See&nbsp;
-                        <a
-                          href="https://lucene.apache.org/core/2_9_4/queryparsersyntax.html"
-                          target="_blank"
-                          rel="noreferer noreferrer"
-                        >
-                          Apache Lucene - Query Parser Syntax <span className="growi-custom-icons">external_link</span>
-                        </a>.
-                      </p>
-                      <div className="accordion" id="accordionId">
-                        <div className="accordion-item p-1">
-                          <h2 className="accordion-header">
-                            <button
-                              className="btn btn-link text-start"
-                              type="button"
-                              onClick={() => this.setState({ isHelpOpened: !this.state.isHelpOpened })}
-                              aria-expanded="true"
-                              aria-controls="ablchelp"
-                            >
-                              <span
-                                className="material-symbols-outlined me-1"
-                                small
-                              >{this.state.isHelpOpened ? 'expand_more' : 'chevron_right'}
-                              </span> Show more...
-                            </button>
-                          </h2>
-                          <Collapse isOpen={this.state.isHelpOpened}>
-                            <div className="accordion-body">
-                              <p dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.attr_based_login_control_rule_help') }} />
-                              <p dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.attr_based_login_control_rule_example1') }} />
-                              <p dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.attr_based_login_control_rule_example2') }} />
-                            </div>
-                          </Collapse>
-                        </div>
-                      </div>
-                    </div>
-                  </td>
-                  <td>
-                    <textarea
-                      className="form-control"
-                      type="text"
-                      value={adminSamlSecurityContainer.state.envABLCRule || ''}
-                      readOnly
-                    />
-                    <p className="form-text text-muted">
-                      <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ABLC_RULE' }) }} />
-                    </p>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
-
-            <div className="row my-3">
-              <div className="offset-3 col-5">
-                <button
-                  type="button"
-                  className="btn btn-primary"
-                  disabled={adminSamlSecurityContainer.state.retrieveError != null}
-                  onClick={this.onClickSubmit}
-                >
-                  {t('Update')}
-                </button>
-              </div>
-            </div>
-
-          </React.Fragment>
-        )}
-
-      </React.Fragment>
-    );
-
-  }
-
-}
-
-SamlSecurityManagementContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminSamlSecurityContainer: PropTypes.instanceOf(AdminSamlSecurityContainer).isRequired,
-  siteUrl: PropTypes.string,
-};
-
-const SamlSecurityManagementContentsWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
-  return <SamlSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
-};
-
-const SamlSecurityManagementContentsWrapper = withUnstatedContainers(SamlSecurityManagementContentsWrapperFC, [
-  AdminGeneralSecurityContainer,
-  AdminSamlSecurityContainer,
-]);
-
-export default SamlSecurityManagementContentsWrapper;

+ 552 - 0
apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx

@@ -0,0 +1,552 @@
+/* eslint-disable react/no-danger */
+import React, { useState, useEffect, useCallback } from 'react';
+
+import { pathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
+import { Collapse } from 'reactstrap';
+import urljoin from 'url-join';
+
+
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSiteUrl } from '~/stores-universal/context';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  adminSamlSecurityContainer: AdminSamlSecurityContainer;
+};
+
+const SamlSecurityManagementContents = (props: Props) => {
+  const {
+    adminGeneralSecurityContainer, adminSamlSecurityContainer,
+  } = props;
+
+  const { t } = useTranslation('admin');
+  const { data: siteUrl } = useSiteUrl();
+
+  const [isHelpOpened, setIsHelpOpened] = useState(false);
+  const { register, handleSubmit, reset } = useForm();
+
+  useEffect(() => {
+    reset({
+      samlEntryPoint: adminSamlSecurityContainer.state.samlEntryPoint || '',
+      samlIssuer: adminSamlSecurityContainer.state.samlIssuer || '',
+      samlCert: adminSamlSecurityContainer.state.samlCert || '',
+      samlAttrMapId: adminSamlSecurityContainer.state.samlAttrMapId || '',
+      samlAttrMapUsername: adminSamlSecurityContainer.state.samlAttrMapUsername || '',
+      samlAttrMapMail: adminSamlSecurityContainer.state.samlAttrMapMail || '',
+      samlAttrMapFirstName: adminSamlSecurityContainer.state.samlAttrMapFirstName || '',
+      samlAttrMapLastName: adminSamlSecurityContainer.state.samlAttrMapLastName || '',
+      samlABLCRule: adminSamlSecurityContainer.state.samlABLCRule || '',
+    });
+  }, [adminSamlSecurityContainer.state, reset]);
+
+  const onSubmit = useCallback(async(data) => {
+    adminSamlSecurityContainer.changeSamlEntryPoint(data.samlEntryPoint);
+    adminSamlSecurityContainer.changeSamlIssuer(data.samlIssuer);
+    adminSamlSecurityContainer.changeSamlCert(data.samlCert);
+    adminSamlSecurityContainer.changeSamlAttrMapId(data.samlAttrMapId);
+    adminSamlSecurityContainer.changeSamlAttrMapUserName(data.samlAttrMapUsername);
+    adminSamlSecurityContainer.changeSamlAttrMapMail(data.samlAttrMapMail);
+    adminSamlSecurityContainer.changeSamlAttrMapFirstName(data.samlAttrMapFirstName);
+    adminSamlSecurityContainer.changeSamlAttrMapLastName(data.samlAttrMapLastName);
+    adminSamlSecurityContainer.changeSamlABLCRule(data.samlABLCRule);
+
+    try {
+      await adminSamlSecurityContainer.updateSamlSetting();
+      toastSuccess(t('security_settings.SAML.updated_saml'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+    try {
+      await adminGeneralSecurityContainer.retrieveSetupStratedies();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminSamlSecurityContainer, adminGeneralSecurityContainer, t]);
+
+  const { useOnlyEnvVars } = adminSamlSecurityContainer.state;
+  const { isSamlEnabled } = adminGeneralSecurityContainer.state;
+
+  const samlCallbackUrl = urljoin(
+    siteUrl == null ? '' : pathUtils.removeTrailingSlash(siteUrl),
+    '/passport/saml/callback',
+  );
+
+  return (
+    <React.Fragment>
+
+      <h2 className="alert-anchor border-bottom">
+        {t('security_settings.SAML.name')}
+      </h2>
+
+      {useOnlyEnvVars && (
+        <p
+          className="alert alert-info"
+          dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.note for the only env option', { env: 'SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }) }}
+        />
+      )}
+
+      <div className="row mt-4 mb-5">
+        <div className="col-6 offset-3">
+          <div className="form-check form-switch form-check-success">
+            <input
+              id="isSamlEnabled"
+              className="form-check-input"
+              type="checkbox"
+              checked={adminGeneralSecurityContainer.state.isSamlEnabled}
+              onChange={() => { adminGeneralSecurityContainer.switchIsSamlEnabled() }}
+              disabled={adminSamlSecurityContainer.state.useOnlyEnvVars}
+            />
+            <label className="form-label form-check-label" htmlFor="isSamlEnabled">
+              {t('security_settings.SAML.enable_saml')}
+            </label>
+          </div>
+          {(!adminGeneralSecurityContainer.state.setupStrategies.includes('saml') && isSamlEnabled)
+              && <div className="badge text-bg-warning">{t('security_settings.setup_is_not_yet_complete')}</div>}
+        </div>
+      </div>
+
+      <div className="row mb-5">
+        <label className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.callback_URL')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            defaultValue={samlCallbackUrl}
+            readOnly
+          />
+          <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'SAML Identity' })}</p>
+          {(siteUrl == null || siteUrl === '') && (
+            <div className="alert alert-danger">
+              <span className="material-symbols-outlined">error</span>
+              <span
+                // eslint-disable-next-line max-len
+                dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
+              />
+            </div>
+          )}
+        </div>
+      </div>
+
+      {isSamlEnabled && (
+        <form onSubmit={handleSubmit(onSubmit)}>
+
+          {(adminSamlSecurityContainer.state.missingMandatoryConfigKeys.length !== 0) && (
+            <div className="alert alert-danger">
+              {t('security_settings.missing mandatory configs')}
+              <ul className="mb-0">
+                {adminSamlSecurityContainer.state.missingMandatoryConfigKeys.map((configKey) => {
+                  const key = configKey.replace('security:passport-saml:', '');
+                  return <li key={configKey}>{t(`security_settings.form_item_name.${key}`)}</li>;
+                })}
+              </ul>
+            </div>
+          )}
+
+
+          <h3 className="alert-anchor border-bottom mb-3">
+            Basic Settings
+          </h3>
+
+          <table className={`table settings-table ${useOnlyEnvVars && 'use-only-env-vars'}`}>
+            <colgroup>
+              <col className="item-name" />
+              <col className="from-db" />
+              <col className="from-env-vars" />
+            </colgroup>
+            <thead>
+              <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+            </thead>
+            <tbody>
+              <tr>
+                <th>{t('security_settings.form_item_name.entryPoint')}</th>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    readOnly={useOnlyEnvVars}
+                    {...register('samlEntryPoint')}
+                  />
+                </td>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    value={adminSamlSecurityContainer.state.envEntryPoint || ''}
+                    readOnly
+                  />
+                  <p className="form-text text-muted">
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ENTRY_POINT' }) }} />
+                  </p>
+                </td>
+              </tr>
+              <tr>
+                <th>{t('security_settings.form_item_name.issuer')}</th>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    readOnly={useOnlyEnvVars}
+                    {...register('samlIssuer')}
+                  />
+                </td>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    value={adminSamlSecurityContainer.state.envIssuer || ''}
+                    readOnly
+                  />
+                  <p className="form-text text-muted">
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ISSUER' }) }} />
+                  </p>
+                </td>
+              </tr>
+              <tr>
+                <th>{t('security_settings.form_item_name.cert')}</th>
+                <td>
+                  <textarea
+                    className="form-control form-control-sm"
+                    rows={5}
+                    readOnly={useOnlyEnvVars}
+                    {...register('samlCert')}
+                  />
+                  <p>
+                    <small>
+                      {t('security_settings.SAML.cert_detail')}
+                    </small>
+                  </p>
+                  <div>
+                    <small>
+                      e.g.
+                      <pre className="card custom-card">{`-----BEGIN CERTIFICATE-----
+MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
+UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
+...
+crmVwBzbloUO2l6k1ibwD2WVwpdxMKIF5z58HfKAvxZAzCHE7kMEZr1ge30WRXQA
+pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
+-----END CERTIFICATE-----
+                        `}
+                      </pre>
+                    </small>
+                  </div>
+                </td>
+                <td>
+                  <textarea
+                    className="form-control form-control-sm"
+                    rows={5}
+                    readOnly
+                    value={adminSamlSecurityContainer.state.envCert || ''}
+                  />
+                  <p className="form-text text-muted">
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_CERT' }) }} />
+                  </p>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+
+          <h3 className="alert-anchor border-bottom mt-5 mb-3">
+            Attribute Mapping
+          </h3>
+
+          <table className="table settings-table">
+            <colgroup>
+              <col className="item-name" />
+              <col className="from-db" />
+              <col className="from-env-vars" />
+            </colgroup>
+            <thead>
+              <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+            </thead>
+            <tbody>
+              <tr>
+                <th>{t('security_settings.form_item_name.attrMapId')}</th>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    {...register('samlAttrMapId')}
+                  />
+                  <p className="form-text text-muted">
+                    <small>
+                      {t('security_settings.SAML.id_detail')}
+                    </small>
+                  </p>
+                </td>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    value={adminSamlSecurityContainer.state.envAttrMapId || ''}
+                    readOnly
+                  />
+                  <p className="form-text text-muted">
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_ID' }) }} />
+                  </p>
+                </td>
+              </tr>
+              <tr>
+                <th>{t('security_settings.form_item_name.attrMapUsername')}</th>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    {...register('samlAttrMapUsername')}
+                  />
+                  <p className="form-text text-muted">
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.username_detail') }} />
+                  </p>
+                </td>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    value={adminSamlSecurityContainer.state.envAttrMapUsername || ''}
+                    readOnly
+                  />
+                  <p className="form-text text-muted">
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_USERNAME' }) }} />
+                  </p>
+                </td>
+              </tr>
+              <tr>
+                <th>{t('security_settings.form_item_name.attrMapMail')}</th>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    {...register('samlAttrMapMail')}
+                  />
+                  <p className="form-text text-muted">
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.mapping_detail', { target: 'Email' }) }} />
+                  </p>
+                </td>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    value={adminSamlSecurityContainer.state.envAttrMapMail || ''}
+                    readOnly
+                  />
+                  <p className="form-text text-muted">
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_MAIL' }) }} />
+                  </p>
+                </td>
+              </tr>
+              <tr>
+                <th>{t('security_settings.form_item_name.attrMapFirstName')}</th>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    {...register('samlAttrMapFirstName')}
+                  />
+                  <p className="form-text text-muted">
+                    {/* eslint-disable-next-line max-len */}
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.mapping_detail', { target: t('security_settings.form_item_name.attrMapFirstName') }) }} />
+                  </p>
+                </td>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    value={adminSamlSecurityContainer.state.envAttrMapFirstName || ''}
+                    readOnly
+                  />
+                  <p className="form-text text-muted">
+                    <small>
+                      <span dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_FIRST_NAME' }) }} />
+                      <br />
+                      <span dangerouslySetInnerHTML={{ __html: t('security_settings.Use default if both are empty', { target: 'firstName' }) }} />
+                    </small>
+                  </p>
+                </td>
+              </tr>
+              <tr>
+                <th>{t('security_settings.form_item_name.attrMapLastName')}</th>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    {...register('samlAttrMapLastName')}
+                  />
+                  <p className="form-text text-muted">
+                    {/* eslint-disable-next-line max-len */}
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.mapping_detail', { target: t('security_settings.form_item_name.attrMapLastName') }) }} />
+                  </p>
+                </td>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    value={adminSamlSecurityContainer.state.envAttrMapLastName || ''}
+                    readOnly
+                  />
+                  <p className="form-text text-muted">
+                    <small>
+                      <span dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_LAST_NAME' }) }} />
+                      <br />
+                      <span dangerouslySetInnerHTML={{ __html: t('security_settings.Use default if both are empty', { target: 'lastName' }) }} />
+                    </small>
+                  </p>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+
+          <h3 className="alert-anchor border-bottom mt-5 mb-4">
+            Attribute Mapping Options
+          </h3>
+
+          <div className="row ms-3">
+            <div className="form-check form-check-success">
+              <input
+                id="bindByUserName-SAML"
+                className="form-check-input"
+                type="checkbox"
+                checked={adminSamlSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
+                onChange={() => { adminSamlSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+              />
+              <label
+                className="form-label form-check-label"
+                htmlFor="bindByUserName-SAML"
+                dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical') }}
+              />
+            </div>
+            <p className="form-text text-muted">
+              <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical_warn') }} />
+            </p>
+          </div>
+
+          <div className="row mb-5 ms-3">
+            <div className="form-check form-check-success">
+              <input
+                id="bindByEmail-SAML"
+                className="form-check-input"
+                type="checkbox"
+                checked={adminSamlSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
+                onChange={() => { adminSamlSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+              />
+              <label
+                className="form-label form-check-label"
+                htmlFor="bindByEmail-SAML"
+                dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical') }}
+              />
+            </div>
+            <p className="form-text text-muted">
+              <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical_warn') }} />
+            </p>
+          </div>
+
+          <h3 className="alert-anchor border-bottom mb-4">
+            Attribute-based Login Control
+          </h3>
+
+          <p className="form-text text-muted">
+            <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.attr_based_login_control_detail') }} />
+          </p>
+
+          <table className="table settings-table">
+            <colgroup>
+              <col className="item-name" />
+              <col className="from-db" />
+              <col className="from-env-vars" />
+            </colgroup>
+            <thead>
+              <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+            </thead>
+            <tbody>
+              <tr>
+                <th>
+                  { t('security_settings.form_item_name.ABLCRule') }
+                </th>
+                <td>
+                  <textarea
+                    className="form-control"
+                    {...register('samlABLCRule')}
+                  />
+                  <div className="mt-2">
+                    <p>
+                      See&nbsp;
+                      <a
+                        href="https://lucene.apache.org/core/2_9_4/queryparsersyntax.html"
+                        target="_blank"
+                        rel="noreferer noreferrer"
+                      >
+                        Apache Lucene - Query Parser Syntax <span className="growi-custom-icons">external_link</span>
+                      </a>.
+                    </p>
+                    <div className="accordion" id="accordionId">
+                      <div className="accordion-item p-1">
+                        <h2 className="accordion-header">
+                          <button
+                            className="btn btn-link text-start"
+                            type="button"
+                            onClick={() => setIsHelpOpened(!isHelpOpened)}
+                            aria-expanded="true"
+                            aria-controls="ablchelp"
+                          >
+                            <span className="material-symbols-outlined me-1">
+                              {isHelpOpened ? 'expand_more' : 'chevron_right'}
+                            </span> Show more...
+                          </button>
+                        </h2>
+                        <Collapse isOpen={isHelpOpened}>
+                          <div className="accordion-body">
+                            <p dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.attr_based_login_control_rule_help') }} />
+                            <p dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.attr_based_login_control_rule_example1') }} />
+                            <p dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.attr_based_login_control_rule_example2') }} />
+                          </div>
+                        </Collapse>
+                      </div>
+                    </div>
+                  </div>
+                </td>
+                <td>
+                  <textarea
+                    className="form-control"
+                    value={adminSamlSecurityContainer.state.envABLCRule || ''}
+                    readOnly
+                  />
+                  <p className="form-text text-muted">
+                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ABLC_RULE' }) }} />
+                  </p>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+
+          <div className="row my-3">
+            <div className="offset-3 col-5">
+              <button
+                type="submit"
+                className="btn btn-primary"
+                disabled={adminSamlSecurityContainer.state.retrieveError != null}
+              >
+                {t('Update')}
+              </button>
+            </div>
+          </div>
+
+        </form>
+      )}
+
+    </React.Fragment>
+  );
+};
+
+const SamlSecurityManagementContentsWrapper = withUnstatedContainers(SamlSecurityManagementContents, [
+  AdminGeneralSecurityContainer,
+  AdminSamlSecurityContainer,
+]);
+
+export default SamlSecurityManagementContentsWrapper;

+ 1 - 1
apps/app/src/client/components/Admin/Security/SecurityManagementContents.jsx

@@ -12,7 +12,7 @@ import LdapSecuritySetting from './LdapSecuritySetting';
 import LocalSecuritySetting from './LocalSecuritySetting';
 import OidcSecuritySetting from './OidcSecuritySetting';
 import SamlSecuritySetting from './SamlSecuritySetting';
-import SecuritySetting from './SecuritySetting';
+import { SecuritySetting } from './SecuritySetting';
 import ShareLinkSetting from './ShareLinkSetting';
 
 const SecurityManagementContents = () => {

+ 0 - 635
apps/app/src/client/components/Admin/Security/SecuritySetting.jsx

@@ -1,635 +0,0 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { Collapse } from 'reactstrap';
-
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { PageDeleteConfigValue } from '~/interfaces/page-delete-config';
-import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-// used as the prefix of translation
-const DeletionTypeForT = Object.freeze({
-  Deletion: 'deletion',
-  CompleteDeletion: 'complete_deletion',
-  RecursiveDeletion: 'recursive_deletion',
-  RecursiveCompleteDeletion: 'recursive_complete_deletion',
-});
-
-const DeletionType = Object.freeze({
-  Deletion: 'deletion',
-  CompleteDeletion: 'completeDeletion',
-  RecursiveDeletion: 'recursiveDeletion',
-  RecursiveCompleteDeletion: 'recursiveCompleteDeletion',
-});
-
-const getDeletionTypeForT = (deletionType) => {
-  switch (deletionType) {
-    case DeletionType.Deletion:
-      return DeletionTypeForT.Deletion;
-    case DeletionType.RecursiveDeletion:
-      return DeletionTypeForT.RecursiveDeletion;
-    case DeletionType.CompleteDeletion:
-      return DeletionTypeForT.CompleteDeletion;
-    case DeletionType.RecursiveCompleteDeletion:
-      return DeletionTypeForT.RecursiveCompleteDeletion;
-  }
-};
-
-const getDeleteConfigValueForT = (DeleteConfigValue) => {
-  switch (DeleteConfigValue) {
-    case PageDeleteConfigValue.Anyone:
-    case null:
-      return 'security_settings.anyone';
-    case PageDeleteConfigValue.Inherit:
-      return 'security_settings.inherit';
-    case PageDeleteConfigValue.AdminOnly:
-      return 'security_settings.admin_only';
-    case PageDeleteConfigValue.AdminAndAuthor:
-      return 'security_settings.admin_and_author';
-  }
-};
-
-/**
- * Return true if "deletionType" is DeletionType.RecursiveDeletion or DeletionType.RecursiveCompleteDeletion.
- * @param deletionType Deletion type
- * @returns boolean
- */
-const isRecursiveDeletion = (deletionType) => {
-  return deletionType === DeletionType.RecursiveDeletion || deletionType === DeletionType.RecursiveCompleteDeletion;
-};
-
-/**
- * Return true if "deletionType" is DeletionType.Deletion or DeletionType.RecursiveDeletion.
- * @param deletionType Deletion type
- * @returns boolean
- */
-const isTypeDeletion = (deletionType) => {
-  return deletionType === DeletionType.Deletion || deletionType === DeletionType.RecursiveDeletion;
-};
-
-class SecuritySetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    // functions
-    this.putSecuritySetting = this.putSecuritySetting.bind(this);
-    this.getRecursiveDeletionConfigState = this.getRecursiveDeletionConfigState.bind(this);
-    this.previousPageRecursiveAuthorityState = this.previousPageRecursiveAuthorityState.bind(this);
-    this.setPagePreviousRecursiveAuthorityState = this.setPagePreviousRecursiveAuthorityState.bind(this);
-    this.expantDeleteOptionsState = this.expantDeleteOptionsState.bind(this);
-    this.setExpantOtherDeleteOptionsState = this.setExpantOtherDeleteOptionsState.bind(this);
-    this.setDeletionConfigState = this.setDeletionConfigState.bind(this);
-
-    // render
-    this.renderPageDeletePermission = this.renderPageDeletePermission.bind(this);
-    this.renderPageDeletePermissionDropdown = this.renderPageDeletePermissionDropdown.bind(this);
-  }
-
-  async putSecuritySetting() {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    try {
-      await adminGeneralSecurityContainer.updateGeneralSecuritySetting();
-      toastSuccess(t('security_settings.updated_general_security_setting'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  getRecursiveDeletionConfigState(deletionType) {
-    const { adminGeneralSecurityContainer } = this.props;
-
-    if (isTypeDeletion(deletionType)) {
-      return [
-        adminGeneralSecurityContainer.state.currentPageRecursiveDeletionAuthority,
-        adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority,
-      ];
-    }
-
-    return [
-      adminGeneralSecurityContainer.state.currentPageRecursiveCompleteDeletionAuthority,
-      adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority,
-    ];
-  }
-
-  previousPageRecursiveAuthorityState(deletionType) {
-    const { adminGeneralSecurityContainer } = this.props;
-
-    return isTypeDeletion(deletionType)
-      ? adminGeneralSecurityContainer.state.previousPageRecursiveDeletionAuthority
-      : adminGeneralSecurityContainer.state.previousPageRecursiveCompleteDeletionAuthority;
-  }
-
-  setPagePreviousRecursiveAuthorityState(deletionType, previousState) {
-    const { adminGeneralSecurityContainer } = this.props;
-
-    if (isTypeDeletion(deletionType)) {
-      adminGeneralSecurityContainer.changePreviousPageRecursiveDeletionAuthority(previousState);
-      return;
-    }
-
-    adminGeneralSecurityContainer.changePreviousPageRecursiveCompleteDeletionAuthority(previousState);
-  }
-
-  expantDeleteOptionsState(deletionType) {
-    const { adminGeneralSecurityContainer } = this.props;
-
-    return isTypeDeletion(deletionType)
-      ? adminGeneralSecurityContainer.state.expandOtherOptionsForDeletion
-      : adminGeneralSecurityContainer.state.expandOtherOptionsForCompleteDeletion;
-  }
-
-  setExpantOtherDeleteOptionsState(deletionType, bool) {
-    const { adminGeneralSecurityContainer } = this.props;
-
-    if (isTypeDeletion(deletionType)) {
-      adminGeneralSecurityContainer.switchExpandOtherOptionsForDeletion(bool);
-      return;
-    }
-    adminGeneralSecurityContainer.switchExpandOtherOptionsForCompleteDeletion(bool);
-    return;
-  }
-
-  /**
-   * Force update deletion config for recursive operation when the deletion config for general operation is updated.
-   * @param deletionType Deletion type
-   */
-  setDeletionConfigState(newState, setState, deletionType) {
-    setState(newState);
-
-    if (this.previousPageRecursiveAuthorityState(deletionType) !== null) {
-      this.setPagePreviousRecursiveAuthorityState(deletionType, null);
-    }
-
-    if (isRecursiveDeletion(deletionType)) {
-      return;
-    }
-
-    const [recursiveState, setRecursiveState] = this.getRecursiveDeletionConfigState(deletionType);
-
-    const calculableValue = prepareDeleteConfigValuesForCalc(newState, recursiveState);
-    const shouldForceUpdate = !validateDeleteConfigs(calculableValue[0], calculableValue[1]);
-    if (shouldForceUpdate) {
-      setRecursiveState(newState);
-      this.setPagePreviousRecursiveAuthorityState(deletionType, recursiveState);
-      this.setExpantOtherDeleteOptionsState(deletionType, true);
-    }
-
-    return;
-  }
-
-  renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled) {
-    const { t } = this.props;
-    return (
-      <div className="dropdown">
-        <button
-          className="btn btn-outline-secondary dropdown-toggle text-end"
-          type="button"
-          id="dropdownMenuButton"
-          data-bs-toggle="dropdown"
-          aria-haspopup="true"
-          aria-expanded="true"
-        >
-          <span className="float-start">
-            {t(getDeleteConfigValueForT(currentState))}
-          </span>
-        </button>
-        <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-          {
-            isRecursiveDeletion(deletionType)
-              ? (
-                <button
-                  className="dropdown-item"
-                  type="button"
-                  onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Inherit, setState, deletionType) }}
-                >
-                  {t('security_settings.inherit')}
-                </button>
-              )
-              : (
-                <button
-                  className="dropdown-item"
-                  type="button"
-                  onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.Anyone, setState, deletionType) }}
-                >
-                  {t('security_settings.anyone')}
-                </button>
-              )
-          }
-          <button
-            className={`dropdown-item ${isButtonDisabled ? 'disabled' : ''}`}
-            type="button"
-            onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminAndAuthor, setState, deletionType) }}
-          >
-            {t('security_settings.admin_and_author')}
-          </button>
-          <button
-            className="dropdown-item"
-            type="button"
-            onClick={() => { this.setDeletionConfigState(PageDeleteConfigValue.AdminOnly, setState, deletionType) }}
-          >
-            {t('security_settings.admin_only')}
-          </button>
-        </div>
-        <p className="form-text text-muted small">
-          {t(`security_settings.${getDeletionTypeForT(deletionType)}_explanation`)}
-        </p>
-      </div>
-    );
-  }
-
-  renderPageDeletePermission(currentState, setState, deletionType, isButtonDisabled) {
-    const { t, adminGeneralSecurityContainer } = this.props;
-
-    const expantDeleteOptionsState = this.expantDeleteOptionsState(deletionType);
-
-    return (
-      <div key={`page-delete-permission-dropdown-${deletionType}`} className="row">
-
-        <div className="col-md-4 text-md-end">
-          {!isRecursiveDeletion(deletionType) && isTypeDeletion(deletionType) && (
-            <strong>{t('security_settings.page_delete')}</strong>
-          )}
-          {!isRecursiveDeletion(deletionType) && !isTypeDeletion(deletionType) && (
-            <strong>{t('security_settings.page_delete_completely')}</strong>
-          )}
-        </div>
-
-        <div className="col-md-8">
-          {
-            !isRecursiveDeletion(deletionType)
-              ? (
-                <>
-                  {this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
-                  {currentState === PageDeleteConfigValue.Anyone && deletionType === DeletionType.CompleteDeletion && (
-                    <>
-                      <input
-                        id="isAllGroupMembershipRequiredForPageCompleteDeletionCheckbox"
-                        className="form-check-input"
-                        type="checkbox"
-                        checked={adminGeneralSecurityContainer.state.isAllGroupMembershipRequiredForPageCompleteDeletion}
-                        onChange={() => { adminGeneralSecurityContainer.switchIsAllGroupMembershipRequiredForPageCompleteDeletion() }}
-                      />
-                      <label className="form-check-label" htmlFor="isAllGroupMembershipRequiredForPageCompleteDeletionCheckbox">
-                        {t('security_settings.is_all_group_membership_required_for_page_complete_deletion')}
-                      </label>
-                      <p
-                        className="form-text text-muted small mt-2"
-                      >
-                        {t('security_settings.is_all_group_membership_required_for_page_complete_deletion_explanation')}
-                      </p>
-                    </>
-                  )}
-                </>
-              )
-              : (
-                <>
-                  <button
-                    type="button"
-                    className="btn btn-link p-0 mb-4"
-                    aria-expanded="false"
-                    onClick={() => this.setExpantOtherDeleteOptionsState(deletionType, !expantDeleteOptionsState)}
-                  >
-                    <span className={`material-symbols-outlined me-1 ${expantDeleteOptionsState ? 'rotate-90' : ''}`}>navigate_next</span>
-                    {t('security_settings.other_options')}
-                  </button>
-                  <Collapse isOpen={expantDeleteOptionsState}>
-                    <div className="pb-4">
-                      <p className="card custom-card bg-warning-sublte">
-                        <span className="text-warning">
-                          <span className="material-symbols-outlined">info</span>
-                          {/* eslint-disable-next-line react/no-danger */}
-                          <span dangerouslySetInnerHTML={{ __html: t('security_settings.page_delete_rights_caution') }} />
-                        </span>
-                      </p>
-                      {this.previousPageRecursiveAuthorityState(deletionType) !== null && (
-                        <div className="mb-3">
-                          <strong>
-                            {t('security_settings.forced_update_desc')}
-                          </strong>
-                          <code>
-                            {t(getDeleteConfigValueForT(this.previousPageRecursiveAuthorityState(deletionType)))}
-                          </code>
-                        </div>
-                      )}
-                      {this.renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
-                    </div>
-                  </Collapse>
-                </>
-              )
-          }
-        </div>
-      </div>
-    );
-  }
-
-  render() {
-    const { t, adminGeneralSecurityContainer } = this.props;
-    const {
-      currentRestrictGuestMode, currentPageDeletionAuthority, currentPageCompleteDeletionAuthority,
-      currentPageRecursiveDeletionAuthority, currentPageRecursiveCompleteDeletionAuthority, isRomUserAllowedToComment,
-    } = adminGeneralSecurityContainer.state;
-
-    const isButtonDisabledForDeletion = !validateDeleteConfigs(
-      adminGeneralSecurityContainer.state.currentPageDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor,
-    );
-
-    const isButtonDisabledForCompleteDeletion = !validateDeleteConfigs(
-      adminGeneralSecurityContainer.state.currentPageCompleteDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor,
-    );
-
-    return (
-      <React.Fragment>
-        <h2 className="alert-anchor border-bottom">
-          {t('security_settings.security_settings')}
-        </h2>
-
-        {adminGeneralSecurityContainer.retrieveError != null && (
-          <div className="alert alert-danger">
-            <p>{t('Error occurred')} : {adminGeneralSecurityContainer.retrieveError}</p>
-          </div>
-        )}
-
-        <h4 className="alert-anchor border-bottom mt-4">{t('security_settings.page_list_and_search_results')}</h4>
-        <div className="row mb-4">
-          <div className="col-md-10">
-            <div className="row">
-
-              {/* Left Column: Labels */}
-              <div className="col-5 d-flex flex-column align-items-end p-4">
-                <div className="fw-bold mb-4">{t('public')}</div>
-                <div className="fw-bold mb-4">{t('anyone_with_the_link')}</div>
-                <div className="fw-bold mb-4">{t('only_me')}</div>
-                <div className="fw-bold">{t('only_inside_the_group')}</div>
-              </div>
-
-              {/* Right Column: Content */}
-              <div className="col-7 d-flex flex-column align-items-start pt-4 pb-4">
-                <div className="mb-4 d-flex align-items-center">
-                  <span className="material-symbols-outlined text-success me-1"></span>
-                  {t('security_settings.always_displayed')}
-                </div>
-                <div className="mb-3 d-flex align-items-center">
-                  <span className="material-symbols-outlined text-danger me-1"></span>
-                  {t('security_settings.always_hidden')}
-                </div>
-
-                {/* Owner Restriction Dropdown */}
-                <div className="mb-3">
-                  <div className="dropdown">
-                    <button
-                      className="btn btn-outline-secondary dropdown-toggle text-end col-12 col-md-auto"
-                      type="button"
-                      id="isShowRestrictedByOwner"
-                      data-bs-toggle="dropdown"
-                      aria-haspopup="true"
-                      aria-expanded="true"
-                    >
-                      <span className="float-start">
-                        {adminGeneralSecurityContainer.state.currentOwnerRestrictionDisplayMode === 'Displayed' && t('security_settings.always_displayed')}
-                        {adminGeneralSecurityContainer.state.currentOwnerRestrictionDisplayMode === 'Hidden' && t('security_settings.always_hidden')}
-                      </span>
-                    </button>
-                    <div className="dropdown-menu" aria-labelledby="isShowRestrictedByOwner">
-                      <button
-                        className="dropdown-item"
-                        type="button"
-                        onClick={() => { adminGeneralSecurityContainer.changeOwnerRestrictionDisplayMode('Displayed') }}
-                      >
-                        {t('security_settings.always_displayed')}
-                      </button>
-                      <button
-                        className="dropdown-item"
-                        type="button"
-                        onClick={() => { adminGeneralSecurityContainer.changeOwnerRestrictionDisplayMode('Hidden') }}
-                      >
-                        {t('security_settings.always_hidden')}
-                      </button>
-                    </div>
-                  </div>
-                </div>
-
-                {/* Group Restriction Dropdown */}
-                <div className="">
-                  <div className="dropdown">
-                    <button
-                      className="btn btn-outline-secondary dropdown-toggle text-end col-12 col-md-auto"
-                      type="button"
-                      id="isShowRestrictedByGroup"
-                      data-bs-toggle="dropdown"
-                      aria-haspopup="true"
-                      aria-expanded="true"
-                    >
-                      <span className="float-start">
-                        {adminGeneralSecurityContainer.state.currentGroupRestrictionDisplayMode === 'Displayed' && t('security_settings.always_displayed')}
-                        {adminGeneralSecurityContainer.state.currentGroupRestrictionDisplayMode === 'Hidden' && t('security_settings.always_hidden')}
-                      </span>
-                    </button>
-                    <div className="dropdown-menu" aria-labelledby="isShowRestrictedByGroup">
-                      <button
-                        className="dropdown-item"
-                        type="button"
-                        onClick={() => { adminGeneralSecurityContainer.changeGroupRestrictionDisplayMode('Displayed') }}
-                      >
-                        {t('security_settings.always_displayed')}
-                      </button>
-                      <button
-                        className="dropdown-item"
-                        type="button"
-                        onClick={() => { adminGeneralSecurityContainer.changeGroupRestrictionDisplayMode('Hidden') }}
-                      >
-                        {t('security_settings.always_hidden')}
-                      </button>
-                    </div>
-                  </div>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <h4 className="mb-3">{t('security_settings.page_access_rights')}</h4>
-        <div className="row mb-4">
-          <div className="col-md-4 text-md-end py-2">
-            <strong>{t('security_settings.Guest Users Access')}</strong>
-          </div>
-          <div className="col-md-8">
-            <div className="dropdown">
-              <button
-                className={`btn btn-outline-secondary dropdown-toggle text-end col-12
-                            col-md-auto ${adminGeneralSecurityContainer.isWikiModeForced && 'disabled'}`}
-                type="button"
-                id="dropdownMenuButton"
-                data-bs-toggle="dropdown"
-                aria-haspopup="true"
-                aria-expanded="true"
-              >
-                <span className="float-start">
-                  {currentRestrictGuestMode === 'Deny' && t('security_settings.guest_mode.deny')}
-                  {currentRestrictGuestMode === 'Readonly' && t('security_settings.guest_mode.readonly')}
-                </span>
-              </button>
-              <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.changeRestrictGuestMode('Deny') }}>
-                  {t('security_settings.guest_mode.deny')}
-                </button>
-                <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.changeRestrictGuestMode('Readonly') }}>
-                  {t('security_settings.guest_mode.readonly')}
-                </button>
-              </div>
-            </div>
-            {adminGeneralSecurityContainer.isWikiModeForced && (
-              <p className="alert alert-warning mt-2 col-6">
-                <span className="material-symbols-outlined me-1">error</span>
-                <b>FIXED</b><br />
-                <b
-                  dangerouslySetInnerHTML={{
-                    __html: t('security_settings.Fixed by env var',
-                      { key: 'FORCE_WIKI_MODE', value: adminGeneralSecurityContainer.state.wikiMode }),
-                  }}
-                />
-              </p>
-            )}
-          </div>
-        </div>
-
-        <h4 className="mb-3">{t('security_settings.page_delete_rights')}</h4>
-        {/* Render PageDeletePermission */}
-        {
-          [
-            [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion, false],
-            // eslint-disable-next-line max-len
-            [currentPageRecursiveDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority, DeletionType.RecursiveDeletion, isButtonDisabledForDeletion],
-          ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
-        }
-        {
-          [
-            [currentPageCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageCompleteDeletionAuthority, DeletionType.CompleteDeletion, false],
-            // eslint-disable-next-line max-len
-            [currentPageRecursiveCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority, DeletionType.RecursiveCompleteDeletion, isButtonDisabledForCompleteDeletion],
-          ].map(arr => this.renderPageDeletePermission(arr[0], arr[1], arr[2], arr[3]))
-        }
-
-        <h4 className="mb-3">{t('security_settings.user_homepage_deletion.user_homepage_deletion')}</h4>
-        <div className="row mb-4">
-          <div className="col-md-10 offset-md-2">
-            <div className="form-check form-switch form-check-success">
-              <input
-                type="checkbox"
-                className="form-check-input"
-                id="is-user-page-deletion-enabled"
-                checked={adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
-                onChange={() => { adminGeneralSecurityContainer.switchIsUsersHomepageDeletionEnabled() }}
-              />
-              <label className="form-label form-check-label" htmlFor="is-user-page-deletion-enabled">
-                {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
-              </label>
-            </div>
-            <div className="custom-control custom-switch custom-checkbox-success mt-2">
-              <input
-                type="checkbox"
-                className="form-check-input"
-                id="is-force-delete-user-homepage-on-user-deletion"
-                checked={adminGeneralSecurityContainer.state.isForceDeleteUserHomepageOnUserDeletion}
-                onChange={() => { adminGeneralSecurityContainer.switchIsForceDeleteUserHomepageOnUserDeletion() }}
-                disabled={!adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
-              />
-              <label className="form-check-label" htmlFor="is-force-delete-user-homepage-on-user-deletion">
-                {t('security_settings.user_homepage_deletion.enable_force_delete_user_homepage_on_user_deletion')}
-              </label>
-            </div>
-            <p
-              className="form-text text-muted small mt-2"
-              dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
-            />
-          </div>
-        </div>
-
-        <h4 className="mb-3">{t('security_settings.comment_manage_rights')}</h4>
-        <div className="row mb-4">
-          <div className="col-md-4 text-md-end py-2">
-            <strong>{t('security_settings.readonly_users_access')}</strong>
-          </div>
-          <div className="col-md-8">
-            <div className="dropdown">
-              <button
-                className={`btn btn-outline-secondary dropdown-toggle text-end col-12
-                            col-md-auto ${adminGeneralSecurityContainer.isWikiModeForced && 'disabled'}`}
-                type="button"
-                id="dropdownMenuButton"
-                data-bs-toggle="dropdown"
-                aria-haspopup="true"
-                aria-expanded="true"
-              >
-                <span className="float-start">
-                  {isRomUserAllowedToComment === true && t('security_settings.read_only_users_comment.accept')}
-                  {isRomUserAllowedToComment === false && t('security_settings.read_only_users_comment.deny')}
-                </span>
-              </button>
-              <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
-                <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(false) }}>
-                  {t('security_settings.read_only_users_comment.deny')}
-                </button>
-                <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(true) }}>
-                  {t('security_settings.read_only_users_comment.accept')}
-                </button>
-              </div>
-            </div>
-          </div>
-        </div>
-
-        <h4>{t('security_settings.session')}</h4>
-        <div className="row">
-          <label className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.max_age')}</label>
-          <div className="col-md-8">
-            <input
-              className="form-control col-md-4"
-              type="text"
-              value={adminGeneralSecurityContainer.state.sessionMaxAge || ''}
-              onChange={(e) => {
-                adminGeneralSecurityContainer.setSessionMaxAge(e.target.value);
-              }}
-              placeholder="2592000000"
-            />
-            {/* eslint-disable-next-line react/no-danger */}
-            <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('security_settings.max_age_desc') }} />
-            <p className="card custom-card bg-warning-subtle">
-              <span className="text-warning">
-                <span className="material-symbols-outlined">info</span> {t('security_settings.max_age_caution')}
-              </span>
-            </p>
-          </div>
-        </div>
-
-        <div className="row my-3">
-          <div className="text-center text-md-start offset-md-3 col-md-5">
-            <button type="button" className="btn btn-primary" disabled={adminGeneralSecurityContainer.retrieveError != null} onClick={this.putSecuritySetting}>
-              {t('Update')}
-            </button>
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-SecuritySetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-};
-
-const SecuritySettingWrapperFC = (props) => {
-  const { t } = useTranslation('admin');
-  return <SecuritySetting t={t} {...props} />;
-};
-
-const SecuritySettingWrapper = withUnstatedContainers(SecuritySettingWrapperFC, [AdminGeneralSecurityContainer]);
-
-export default SecuritySettingWrapper;

+ 58 - 0
apps/app/src/client/components/Admin/Security/SecuritySetting/CommentManageRightsSettings.tsx

@@ -0,0 +1,58 @@
+import React from 'react';
+
+import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  t: (key: string) => string;
+};
+
+export const CommentManageRightsSettings: React.FC<Props> = ({ adminGeneralSecurityContainer, t }) => {
+  const { isRomUserAllowedToComment } = adminGeneralSecurityContainer.state;
+
+  return (
+    <>
+      <h4 className="mb-3">{t('security_settings.comment_manage_rights')}</h4>
+      <div className="row mb-4">
+        <div className="col-md-4 text-md-end py-2">
+          <strong>{t('security_settings.readonly_users_access')}</strong>
+        </div>
+        <div className="col-md-8">
+          <div className="dropdown">
+            <button
+              className={`btn btn-outline-secondary dropdown-toggle text-end col-12 col-md-auto ${
+                adminGeneralSecurityContainer.isWikiModeForced && 'disabled'
+              }`}
+              type="button"
+              id="dropdownMenuButton"
+              data-bs-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              <span className="float-start">
+                {isRomUserAllowedToComment === true && t('security_settings.read_only_users_comment.accept')}
+                {isRomUserAllowedToComment === false && t('security_settings.read_only_users_comment.deny')}
+              </span>
+            </button>
+            <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(false) }}
+              >
+                {t('security_settings.read_only_users_comment.deny')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(true) }}
+              >
+                {t('security_settings.read_only_users_comment.accept')}
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </>
+  );
+};

+ 75 - 0
apps/app/src/client/components/Admin/Security/SecuritySetting/PageAccessRightsSettings.tsx

@@ -0,0 +1,75 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+
+import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  t: (key: string, options?: Record<string, unknown>) => string;
+};
+
+export const PageAccessRightsSettings: React.FC<Props> = ({ adminGeneralSecurityContainer, t }) => {
+  const { currentRestrictGuestMode } = adminGeneralSecurityContainer.state;
+
+  return (
+    <>
+      <h4 className="mb-3">{t('security_settings.page_access_rights')}</h4>
+      <div className="row mb-4">
+        <div className="col-md-4 text-md-end py-2">
+          <strong>{t('security_settings.Guest Users Access')}</strong>
+        </div>
+        <div className="col-md-8">
+          <div className="dropdown">
+            <button
+              className={`btn btn-outline-secondary dropdown-toggle text-end col-12 col-md-auto ${
+                adminGeneralSecurityContainer.isWikiModeForced && 'disabled'
+              }`}
+              type="button"
+              id="dropdownMenuButton"
+              data-bs-toggle="dropdown"
+              aria-haspopup="true"
+              aria-expanded="true"
+            >
+              <span className="float-start">
+                {currentRestrictGuestMode === 'Deny' && t('security_settings.guest_mode.deny')}
+                {currentRestrictGuestMode === 'Readonly' && t('security_settings.guest_mode.readonly')}
+              </span>
+            </button>
+            <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminGeneralSecurityContainer.changeRestrictGuestMode('Deny') }}
+              >
+                {t('security_settings.guest_mode.deny')}
+              </button>
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { adminGeneralSecurityContainer.changeRestrictGuestMode('Readonly') }}
+              >
+                {t('security_settings.guest_mode.readonly')}
+              </button>
+            </div>
+          </div>
+          {adminGeneralSecurityContainer.isWikiModeForced && (
+            <p className="alert alert-warning mt-2 col-6">
+              <span className="material-symbols-outlined me-1">error</span>
+              <b>FIXED</b>
+              <br />
+              {/* eslint-disable-next-line react/no-danger */}
+              <b
+                dangerouslySetInnerHTML={{
+                  __html: t('security_settings.Fixed by env var', {
+                    key: 'FORCE_WIKI_MODE',
+                    value: adminGeneralSecurityContainer.state.wikiMode,
+                  }),
+                }}
+              />
+            </p>
+          )}
+        </div>
+      </div>
+    </>
+  );
+};

+ 289 - 0
apps/app/src/client/components/Admin/Security/SecuritySetting/PageDeleteRightsSettings.tsx

@@ -0,0 +1,289 @@
+import React, { useCallback } from 'react';
+
+import { Collapse } from 'reactstrap';
+
+import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import {
+  PageDeleteConfigValue,
+  type IPageDeleteConfigValue,
+  type IPageDeleteConfigValueToProcessValidation,
+} from '~/interfaces/page-delete-config';
+import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
+
+import {
+  DeletionType,
+  type DeletionTypeValue,
+  getDeletionTypeForT,
+  getDeleteConfigValueForT,
+  isRecursiveDeletion,
+  isTypeDeletion,
+} from './types';
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  t: (key: string) => string;
+};
+
+export const PageDeleteRightsSettings: React.FC<Props> = ({ adminGeneralSecurityContainer, t }) => {
+  const {
+    currentPageDeletionAuthority,
+    currentPageCompleteDeletionAuthority,
+    currentPageRecursiveDeletionAuthority,
+    currentPageRecursiveCompleteDeletionAuthority,
+  } = adminGeneralSecurityContainer.state;
+
+  const getRecursiveDeletionConfigState = useCallback((deletionType: DeletionTypeValue) => {
+    if (isTypeDeletion(deletionType)) {
+      return [
+        adminGeneralSecurityContainer.state.currentPageRecursiveDeletionAuthority,
+        adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority,
+      ] as const;
+    }
+
+    return [
+      adminGeneralSecurityContainer.state.currentPageRecursiveCompleteDeletionAuthority,
+      adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority,
+    ] as const;
+  }, [adminGeneralSecurityContainer]);
+
+  const previousPageRecursiveAuthorityState = useCallback((deletionType: DeletionTypeValue) => {
+    return isTypeDeletion(deletionType)
+      ? adminGeneralSecurityContainer.state.previousPageRecursiveDeletionAuthority
+      : adminGeneralSecurityContainer.state.previousPageRecursiveCompleteDeletionAuthority;
+  }, [adminGeneralSecurityContainer]);
+
+  const setPagePreviousRecursiveAuthorityState = useCallback((deletionType: DeletionTypeValue, previousState: IPageDeleteConfigValue | null) => {
+    if (isTypeDeletion(deletionType)) {
+      adminGeneralSecurityContainer.changePreviousPageRecursiveDeletionAuthority(previousState);
+      return;
+    }
+
+    adminGeneralSecurityContainer.changePreviousPageRecursiveCompleteDeletionAuthority(previousState);
+  }, [adminGeneralSecurityContainer]);
+
+  const expandDeleteOptionsState = useCallback((deletionType: DeletionTypeValue) => {
+    return isTypeDeletion(deletionType)
+      ? adminGeneralSecurityContainer.state.expandOtherOptionsForDeletion
+      : adminGeneralSecurityContainer.state.expandOtherOptionsForCompleteDeletion;
+  }, [adminGeneralSecurityContainer]);
+
+  const setExpandOtherDeleteOptionsState = useCallback((deletionType: DeletionTypeValue, bool: boolean) => {
+    if (isTypeDeletion(deletionType)) {
+      adminGeneralSecurityContainer.switchExpandOtherOptionsForDeletion(bool);
+      return;
+    }
+    adminGeneralSecurityContainer.switchExpandOtherOptionsForCompleteDeletion(bool);
+  }, [adminGeneralSecurityContainer]);
+
+  const setDeletionConfigState = useCallback((
+      newState: IPageDeleteConfigValue,
+      setState: (value: IPageDeleteConfigValue) => void,
+      deletionType: DeletionTypeValue,
+  ) => {
+    setState(newState);
+
+    if (previousPageRecursiveAuthorityState(deletionType) !== null) {
+      setPagePreviousRecursiveAuthorityState(deletionType, null);
+    }
+
+    if (isRecursiveDeletion(deletionType)) {
+      return;
+    }
+
+    const [recursiveState, setRecursiveState] = getRecursiveDeletionConfigState(deletionType);
+
+    const calculableValue = prepareDeleteConfigValuesForCalc(
+      newState as IPageDeleteConfigValueToProcessValidation,
+      recursiveState as IPageDeleteConfigValueToProcessValidation,
+    );
+    const shouldForceUpdate = !validateDeleteConfigs(calculableValue[0], calculableValue[1]);
+    if (shouldForceUpdate) {
+      setRecursiveState(newState);
+      setPagePreviousRecursiveAuthorityState(deletionType, recursiveState);
+      setExpandOtherDeleteOptionsState(deletionType, true);
+    }
+  }, [
+    getRecursiveDeletionConfigState,
+    previousPageRecursiveAuthorityState,
+    setPagePreviousRecursiveAuthorityState,
+    setExpandOtherDeleteOptionsState,
+  ]);
+
+  const renderPageDeletePermissionDropdown = useCallback((
+      currentState: IPageDeleteConfigValue,
+      setState: (value: IPageDeleteConfigValue) => void,
+      deletionType: DeletionTypeValue,
+      isButtonDisabled: boolean,
+  ) => {
+    return (
+      <div className="dropdown">
+        <button
+          className="btn btn-outline-secondary dropdown-toggle text-end"
+          type="button"
+          id="dropdownMenuButton"
+          data-bs-toggle="dropdown"
+          aria-haspopup="true"
+          aria-expanded="true"
+        >
+          <span className="float-start">{t(getDeleteConfigValueForT(currentState))}</span>
+        </button>
+        <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+          {isRecursiveDeletion(deletionType)
+            ? (
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { setDeletionConfigState(PageDeleteConfigValue.Inherit, setState, deletionType) }}
+              >
+                {t('security_settings.inherit')}
+              </button>
+            )
+            : (
+              <button
+                className="dropdown-item"
+                type="button"
+                onClick={() => { setDeletionConfigState(PageDeleteConfigValue.Anyone, setState, deletionType) }}
+              >
+                {t('security_settings.anyone')}
+              </button>
+            )}
+          <button
+            className={`dropdown-item ${isButtonDisabled ? 'disabled' : ''}`}
+            type="button"
+            onClick={() => { setDeletionConfigState(PageDeleteConfigValue.AdminAndAuthor, setState, deletionType) }}
+          >
+            {t('security_settings.admin_and_author')}
+          </button>
+          <button
+            className="dropdown-item"
+            type="button"
+            onClick={() => { setDeletionConfigState(PageDeleteConfigValue.AdminOnly, setState, deletionType) }}
+          >
+            {t('security_settings.admin_only')}
+          </button>
+        </div>
+        <p className="form-text text-muted small">{t(`security_settings.${getDeletionTypeForT(deletionType)}_explanation`)}</p>
+      </div>
+    );
+  }, [t, setDeletionConfigState]);
+
+  const renderPageDeletePermission = useCallback((
+      currentState: IPageDeleteConfigValue,
+      setState: (value: IPageDeleteConfigValue) => void,
+      deletionType: DeletionTypeValue,
+      isButtonDisabled: boolean,
+  ) => {
+    const expandDeleteOptions = expandDeleteOptionsState(deletionType);
+
+    return (
+      <div key={`page-delete-permission-dropdown-${deletionType}`} className="row">
+        <div className="col-md-4 text-md-end">
+          {!isRecursiveDeletion(deletionType) && isTypeDeletion(deletionType) && <strong>{t('security_settings.page_delete')}</strong>}
+          {!isRecursiveDeletion(deletionType) && !isTypeDeletion(deletionType) && <strong>{t('security_settings.page_delete_completely')}</strong>}
+        </div>
+
+        <div className="col-md-8">
+          {!isRecursiveDeletion(deletionType)
+            ? (
+              <>
+                {renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
+                {currentState === PageDeleteConfigValue.Anyone && deletionType === DeletionType.CompleteDeletion && (
+                  <>
+                    <input
+                      id="isAllGroupMembershipRequiredForPageCompleteDeletionCheckbox"
+                      className="form-check-input"
+                      type="checkbox"
+                      checked={adminGeneralSecurityContainer.state.isAllGroupMembershipRequiredForPageCompleteDeletion}
+                      onChange={() => { adminGeneralSecurityContainer.switchIsAllGroupMembershipRequiredForPageCompleteDeletion() }}
+                    />
+                    <label className="form-check-label" htmlFor="isAllGroupMembershipRequiredForPageCompleteDeletionCheckbox">
+                      {t('security_settings.is_all_group_membership_required_for_page_complete_deletion')}
+                    </label>
+                    <p className="form-text text-muted small mt-2">
+                      {t('security_settings.is_all_group_membership_required_for_page_complete_deletion_explanation')}
+                    </p>
+                  </>
+                )}
+              </>
+            )
+            : (
+              <>
+                <button
+                  type="button"
+                  className="btn btn-link p-0 mb-4"
+                  aria-expanded="false"
+                  onClick={() => setExpandOtherDeleteOptionsState(deletionType, !expandDeleteOptions)}
+                >
+                  <span className={`material-symbols-outlined me-1 ${expandDeleteOptions ? 'rotate-90' : ''}`}>navigate_next</span>
+                  {t('security_settings.other_options')}
+                </button>
+                <Collapse isOpen={expandDeleteOptions}>
+                  <div className="pb-4">
+                    <p className="card custom-card bg-warning-sublte">
+                      <span className="text-warning">
+                        <span className="material-symbols-outlined">info</span>
+                        {/* eslint-disable-next-line react/no-danger */}
+                        <span dangerouslySetInnerHTML={{ __html: t('security_settings.page_delete_rights_caution') }} />
+                      </span>
+                    </p>
+                    {previousPageRecursiveAuthorityState(deletionType) !== null && (
+                      <div className="mb-3">
+                        <strong>{t('security_settings.forced_update_desc')}</strong>
+                        <code>{t(getDeleteConfigValueForT(previousPageRecursiveAuthorityState(deletionType)))}</code>
+                      </div>
+                    )}
+                    {renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
+                  </div>
+                </Collapse>
+              </>
+            )}
+        </div>
+      </div>
+    );
+  }, [
+    adminGeneralSecurityContainer,
+    expandDeleteOptionsState,
+    previousPageRecursiveAuthorityState,
+    renderPageDeletePermissionDropdown,
+    setExpandOtherDeleteOptionsState,
+    t,
+  ]);
+
+  const isButtonDisabledForDeletion = !validateDeleteConfigs(currentPageDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor);
+
+  const isButtonDisabledForCompleteDeletion = !validateDeleteConfigs(currentPageCompleteDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor);
+
+  return (
+    <>
+      <h4 className="mb-3">{t('security_settings.page_delete_rights')}</h4>
+      {[
+        [currentPageDeletionAuthority, adminGeneralSecurityContainer.changePageDeletionAuthority, DeletionType.Deletion, false],
+        [
+          currentPageRecursiveDeletionAuthority,
+          adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority,
+          DeletionType.RecursiveDeletion,
+          isButtonDisabledForDeletion,
+        ],
+      ].map(arr => renderPageDeletePermission(
+        arr[0] as IPageDeleteConfigValue,
+        arr[1] as (value: IPageDeleteConfigValue) => void,
+        arr[2] as DeletionTypeValue,
+        arr[3] as boolean,
+      ))}
+      {[
+        [currentPageCompleteDeletionAuthority, adminGeneralSecurityContainer.changePageCompleteDeletionAuthority, DeletionType.CompleteDeletion, false],
+        [
+          currentPageRecursiveCompleteDeletionAuthority,
+          adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority,
+          DeletionType.RecursiveCompleteDeletion,
+          isButtonDisabledForCompleteDeletion,
+        ],
+      ].map(arr => renderPageDeletePermission(
+        arr[0] as IPageDeleteConfigValue,
+        arr[1] as (value: IPageDeleteConfigValue) => void,
+        arr[2] as DeletionTypeValue,
+        arr[3] as boolean,
+      ))}
+    </>
+  );
+};

+ 117 - 0
apps/app/src/client/components/Admin/Security/SecuritySetting/PageListDisplaySettings.tsx

@@ -0,0 +1,117 @@
+import React from 'react';
+
+import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  t: (key: string) => string;
+};
+
+export const PageListDisplaySettings: React.FC<Props> = ({ adminGeneralSecurityContainer, t }) => {
+  return (
+    <>
+      <h4 className="alert-anchor">
+        {t('security_settings.page_list_and_search_results')}
+      </h4>
+      <div className="row mb-4">
+        <div className="col-md-10">
+          <div className="row">
+            {/* Left Column: Labels */}
+            <div className="col-5 d-flex flex-column align-items-end p-4">
+              <div className="fw-bold mb-4">{t('public')}</div>
+              <div className="fw-bold mb-4">{t('anyone_with_the_link')}</div>
+              <div className="fw-bold mb-4">{t('only_me')}</div>
+              <div className="fw-bold">{t('only_inside_the_group')}</div>
+            </div>
+
+            {/* Right Column: Content */}
+            <div className="col-7 d-flex flex-column align-items-start pt-4 pb-4">
+              <div className="mb-4 d-flex align-items-center">
+                <span className="material-symbols-outlined text-success me-1"></span>
+                {t('security_settings.always_displayed')}
+              </div>
+              <div className="mb-3 d-flex align-items-center">
+                <span className="material-symbols-outlined text-danger me-1"></span>
+                {t('security_settings.always_hidden')}
+              </div>
+
+              {/* Owner Restriction Dropdown */}
+              <div className="mb-3">
+                <div className="dropdown">
+                  <button
+                    className="btn btn-outline-secondary dropdown-toggle text-end col-12 col-md-auto"
+                    type="button"
+                    id="isShowRestrictedByOwner"
+                    data-bs-toggle="dropdown"
+                    aria-haspopup="true"
+                    aria-expanded="true"
+                  >
+                    <span className="float-start">
+                      {adminGeneralSecurityContainer.state.currentOwnerRestrictionDisplayMode === 'Displayed'
+                        && t('security_settings.always_displayed')}
+                      {adminGeneralSecurityContainer.state.currentOwnerRestrictionDisplayMode === 'Hidden'
+                        && t('security_settings.always_hidden')}
+                    </span>
+                  </button>
+                  <div className="dropdown-menu" aria-labelledby="isShowRestrictedByOwner">
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => { adminGeneralSecurityContainer.changeOwnerRestrictionDisplayMode('Displayed') }}
+                    >
+                      {t('security_settings.always_displayed')}
+                    </button>
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => { adminGeneralSecurityContainer.changeOwnerRestrictionDisplayMode('Hidden') }}
+                    >
+                      {t('security_settings.always_hidden')}
+                    </button>
+                  </div>
+                </div>
+              </div>
+
+              {/* Group Restriction Dropdown */}
+              <div className="">
+                <div className="dropdown">
+                  <button
+                    className="btn btn-outline-secondary dropdown-toggle text-end col-12 col-md-auto"
+                    type="button"
+                    id="isShowRestrictedByGroup"
+                    data-bs-toggle="dropdown"
+                    aria-haspopup="true"
+                    aria-expanded="true"
+                  >
+                    <span className="float-start">
+                      {adminGeneralSecurityContainer.state.currentGroupRestrictionDisplayMode === 'Displayed'
+                        && t('security_settings.always_displayed')}
+                      {adminGeneralSecurityContainer.state.currentGroupRestrictionDisplayMode === 'Hidden'
+                        && t('security_settings.always_hidden')}
+                    </span>
+                  </button>
+                  <div className="dropdown-menu" aria-labelledby="isShowRestrictedByGroup">
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => { adminGeneralSecurityContainer.changeGroupRestrictionDisplayMode('Displayed') }}
+                    >
+                      {t('security_settings.always_displayed')}
+                    </button>
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => { adminGeneralSecurityContainer.changeGroupRestrictionDisplayMode('Hidden') }}
+                    >
+                      {t('security_settings.always_hidden')}
+                    </button>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </>
+  );
+};

+ 36 - 0
apps/app/src/client/components/Admin/Security/SecuritySetting/SessionMaxAgeSettings.tsx

@@ -0,0 +1,36 @@
+import React from 'react';
+
+import type { UseFormRegister } from 'react-hook-form';
+
+type Props = {
+  register: UseFormRegister<{ sessionMaxAge: string }>;
+  t: (key: string, options?: Record<string, unknown>) => string;
+};
+
+export const SessionMaxAgeSettings: React.FC<Props> = ({ register, t }) => {
+  return (
+    <>
+      <h4>{t('security_settings.session')}</h4>
+      <div className="row">
+        <label className="text-start text-md-end col-md-3 col-form-label">
+          {t('security_settings.max_age')}
+        </label>
+        <div className="col-md-8">
+          <input
+            className="form-control col-md-4"
+            type="text"
+            {...register('sessionMaxAge')}
+            placeholder="2592000000"
+          />
+          {/* eslint-disable-next-line react/no-danger */}
+          <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('security_settings.max_age_desc') }} />
+          <p className="card custom-card bg-warning-subtle">
+            <span className="text-warning">
+              <span className="material-symbols-outlined">info</span> {t('security_settings.max_age_caution')}
+            </span>
+          </p>
+        </div>
+      </div>
+    </>
+  );
+};

+ 50 - 0
apps/app/src/client/components/Admin/Security/SecuritySetting/UserHomepageDeletionSettings.tsx

@@ -0,0 +1,50 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+
+import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  t: (key: string) => string;
+};
+
+export const UserHomepageDeletionSettings: React.FC<Props> = ({ adminGeneralSecurityContainer, t }) => {
+  return (
+    <>
+      <h4 className="mb-3">{t('security_settings.user_homepage_deletion.user_homepage_deletion')}</h4>
+      <div className="row mb-4">
+        <div className="col-md-10 offset-md-2">
+          <div className="form-check form-switch form-check-success">
+            <input
+              type="checkbox"
+              className="form-check-input"
+              id="is-user-page-deletion-enabled"
+              checked={adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
+              onChange={() => { adminGeneralSecurityContainer.switchIsUsersHomepageDeletionEnabled() }}
+            />
+            <label className="form-label form-check-label" htmlFor="is-user-page-deletion-enabled">
+              {t('security_settings.user_homepage_deletion.enable_user_homepage_deletion')}
+            </label>
+          </div>
+          <div className="custom-control custom-switch custom-checkbox-success mt-2">
+            <input
+              type="checkbox"
+              className="form-check-input"
+              id="is-force-delete-user-homepage-on-user-deletion"
+              checked={adminGeneralSecurityContainer.state.isForceDeleteUserHomepageOnUserDeletion}
+              onChange={() => { adminGeneralSecurityContainer.switchIsForceDeleteUserHomepageOnUserDeletion() }}
+              disabled={!adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
+            />
+            <label className="form-check-label" htmlFor="is-force-delete-user-homepage-on-user-deletion">
+              {t('security_settings.user_homepage_deletion.enable_force_delete_user_homepage_on_user_deletion')}
+            </label>
+          </div>
+          <p
+            className="form-text text-muted small mt-2"
+            dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
+          />
+        </div>
+      </div>
+    </>
+  );
+};

+ 88 - 0
apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx

@@ -0,0 +1,88 @@
+import React, { useCallback, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
+
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+
+import { withUnstatedContainers } from '../../../UnstatedUtils';
+
+import { CommentManageRightsSettings } from './CommentManageRightsSettings';
+import { PageAccessRightsSettings } from './PageAccessRightsSettings';
+import { PageDeleteRightsSettings } from './PageDeleteRightsSettings';
+import { PageListDisplaySettings } from './PageListDisplaySettings';
+import { SessionMaxAgeSettings } from './SessionMaxAgeSettings';
+import { UserHomepageDeletionSettings } from './UserHomepageDeletionSettings';
+
+type FormData = {
+  sessionMaxAge: string;
+};
+
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+};
+
+const SecuritySettingComponent: React.FC<Props> = ({ adminGeneralSecurityContainer }) => {
+  const { t } = useTranslation('admin');
+  const { register, handleSubmit, reset } = useForm<FormData>();
+
+  // Initialize form with current sessionMaxAge value
+  useEffect(() => {
+    reset({
+      sessionMaxAge: adminGeneralSecurityContainer.state.sessionMaxAge || '',
+    });
+  }, [reset, adminGeneralSecurityContainer.state.sessionMaxAge]);
+
+  const onSubmit = useCallback(async(data: FormData) => {
+    try {
+      // Update sessionMaxAge from form data
+      await adminGeneralSecurityContainer.setSessionMaxAge(data.sessionMaxAge);
+      // Save all security settings
+      await adminGeneralSecurityContainer.updateGeneralSecuritySetting();
+      toastSuccess(t('security_settings.updated_general_security_setting'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [adminGeneralSecurityContainer, t]);
+
+  if (adminGeneralSecurityContainer.state.retrieveError != null) {
+    return (
+      <div>
+        <p>
+          {t('Error occurred')} : {adminGeneralSecurityContainer.state.retrieveError}
+        </p>
+      </div>
+    );
+  }
+
+  return (
+    <div data-testid="admin-security-setting">
+      <h2 className="border-bottom mb-5">{t('security_settings.security_settings')}</h2>
+
+      <form onSubmit={handleSubmit(onSubmit)}>
+        <div className="vstack gap-3">
+          <PageListDisplaySettings adminGeneralSecurityContainer={adminGeneralSecurityContainer} t={t} />
+          <PageAccessRightsSettings adminGeneralSecurityContainer={adminGeneralSecurityContainer} t={t} />
+          <PageDeleteRightsSettings adminGeneralSecurityContainer={adminGeneralSecurityContainer} t={t} />
+          <UserHomepageDeletionSettings adminGeneralSecurityContainer={adminGeneralSecurityContainer} t={t} />
+          <CommentManageRightsSettings adminGeneralSecurityContainer={adminGeneralSecurityContainer} t={t} />
+          <SessionMaxAgeSettings register={register} t={t} />
+
+          <div className="text-center text-md-start offset-md-3 col-md-5">
+            <button
+              type="submit"
+              className="btn btn-primary"
+              disabled={adminGeneralSecurityContainer.state.retrieveError != null}
+            >
+              {t('Update')}
+            </button>
+          </div>
+        </div>
+      </form>
+    </div>
+  );
+};
+
+export const SecuritySetting = withUnstatedContainers(SecuritySettingComponent, [AdminGeneralSecurityContainer]);

+ 65 - 0
apps/app/src/client/components/Admin/Security/SecuritySetting/types.ts

@@ -0,0 +1,65 @@
+import { PageDeleteConfigValue, type IPageDeleteConfigValue } from '~/interfaces/page-delete-config';
+
+export const DeletionTypeForT = Object.freeze({
+  Deletion: 'deletion',
+  CompleteDeletion: 'complete_deletion',
+  RecursiveDeletion: 'recursive_deletion',
+  RecursiveCompleteDeletion: 'recursive_complete_deletion',
+} as const);
+
+export const DeletionType = Object.freeze({
+  Deletion: 'deletion',
+  CompleteDeletion: 'completeDeletion',
+  RecursiveDeletion: 'recursiveDeletion',
+  RecursiveCompleteDeletion: 'recursiveCompleteDeletion',
+} as const);
+
+export type DeletionTypeKey = keyof typeof DeletionType;
+export type DeletionTypeValue = typeof DeletionType[DeletionTypeKey];
+
+export const getDeletionTypeForT = (deletionType: DeletionTypeValue): string => {
+  switch (deletionType) {
+    case DeletionType.Deletion:
+      return DeletionTypeForT.Deletion;
+    case DeletionType.RecursiveDeletion:
+      return DeletionTypeForT.RecursiveDeletion;
+    case DeletionType.CompleteDeletion:
+      return DeletionTypeForT.CompleteDeletion;
+    case DeletionType.RecursiveCompleteDeletion:
+      return DeletionTypeForT.RecursiveCompleteDeletion;
+  }
+};
+
+export const getDeleteConfigValueForT = (deleteConfigValue: IPageDeleteConfigValue | null): string => {
+  switch (deleteConfigValue) {
+    case PageDeleteConfigValue.Anyone:
+    case null:
+      return 'security_settings.anyone';
+    case PageDeleteConfigValue.Inherit:
+      return 'security_settings.inherit';
+    case PageDeleteConfigValue.AdminOnly:
+      return 'security_settings.admin_only';
+    case PageDeleteConfigValue.AdminAndAuthor:
+      return 'security_settings.admin_and_author';
+    default:
+      return 'security_settings.anyone';
+  }
+};
+
+/**
+ * Return true if "deletionType" is DeletionType.RecursiveDeletion or DeletionType.RecursiveCompleteDeletion.
+ * @param deletionType Deletion type
+ * @returns boolean
+ */
+export const isRecursiveDeletion = (deletionType: DeletionTypeValue): boolean => {
+  return deletionType === DeletionType.RecursiveDeletion || deletionType === DeletionType.RecursiveCompleteDeletion;
+};
+
+/**
+ * Return true if "deletionType" is DeletionType.Deletion or DeletionType.RecursiveDeletion.
+ * @param deletionType Deletion type
+ * @returns boolean
+ */
+export const isTypeDeletion = (deletionType: DeletionTypeValue): boolean => {
+  return deletionType === DeletionType.Deletion || deletionType === DeletionType.RecursiveDeletion;
+};

+ 0 - 235
apps/app/src/client/services/AdminAppContainer.js

@@ -40,41 +40,6 @@ export default class AdminAppContainer extends Container {
       sesAccessKeyId: '',
       sesSecretAccessKey: '',
 
-      fileUploadType: '',
-      envFileUploadType: '',
-      isFixedFileUploadByEnvVar: false,
-
-      gcsUseOnlyEnvVars: false,
-      gcsApiKeyJsonPath: '',
-      envGcsApiKeyJsonPath: '',
-      gcsBucket: '',
-      envGcsBucket: '',
-      gcsUploadNamespace: '',
-      envGcsUploadNamespace: '',
-      gcsReferenceFileWithRelayMode: false,
-
-      s3Region: '',
-      s3CustomEndpoint: '',
-      s3Bucket: '',
-      s3AccessKeyId: '',
-      s3SecretAccessKey: '',
-      s3ReferenceFileWithRelayMode: false,
-
-      azureReferenceFileWithRelayMode: false,
-      azureUseOnlyEnvVars: false,
-      azureTenantId: '',
-      azureClientId: '',
-      azureClientSecret: '',
-      azureStorageAccountName: '',
-      azureStorageContainerName: '',
-      envAzureTenantId: '',
-      envAzureClientId: '',
-      envAzureClientSecret: '',
-      envAzureStorageAccountName: '',
-      envAzureStorageContainerName: '',
-
-      isEnabledPlugins: true,
-
       isMaintenanceMode: false,
 
       // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
@@ -118,51 +83,11 @@ export default class AdminAppContainer extends Container {
       sesAccessKeyId: appSettingsParams.sesAccessKeyId,
       sesSecretAccessKey: appSettingsParams.sesSecretAccessKey,
 
-      fileUploadType: appSettingsParams.fileUploadType,
-      envFileUploadType: appSettingsParams.envFileUploadType,
-      useOnlyEnvVarForFileUploadType: appSettingsParams.useOnlyEnvVarForFileUploadType,
-
-      s3Region: appSettingsParams.s3Region,
-      s3CustomEndpoint: appSettingsParams.s3CustomEndpoint,
-      s3Bucket: appSettingsParams.s3Bucket,
-      s3AccessKeyId: appSettingsParams.s3AccessKeyId,
-      s3ReferenceFileWithRelayMode: appSettingsParams.s3ReferenceFileWithRelayMode,
-
-      gcsUseOnlyEnvVars: appSettingsParams.gcsUseOnlyEnvVars,
-      gcsApiKeyJsonPath: appSettingsParams.gcsApiKeyJsonPath,
-      gcsBucket: appSettingsParams.gcsBucket,
-      gcsUploadNamespace: appSettingsParams.gcsUploadNamespace,
-      gcsReferenceFileWithRelayMode: appSettingsParams.gcsReferenceFileWithRelayMode,
-      envGcsApiKeyJsonPath: appSettingsParams.envGcsApiKeyJsonPath,
-      envGcsBucket: appSettingsParams.envGcsBucket,
-      envGcsUploadNamespace: appSettingsParams.envGcsUploadNamespace,
-
-      azureUseOnlyEnvVars: appSettingsParams.azureUseOnlyEnvVars,
-      azureTenantId: appSettingsParams.azureTenantId,
-      azureClientId: appSettingsParams.azureClientId,
-      azureClientSecret: appSettingsParams.azureClientSecret,
-      azureStorageAccountName: appSettingsParams.azureStorageAccountName,
-      azureStorageContainerName: appSettingsParams.azureStorageContainerName,
-      azureReferenceFileWithRelayMode: appSettingsParams.azureReferenceFileWithRelayMode,
-      envAzureTenantId: appSettingsParams.envAzureTenantId,
-      envAzureClientId: appSettingsParams.envAzureClientId,
-      envAzureClientSecret: appSettingsParams.envAzureClientSecret,
-      envAzureStorageAccountName: appSettingsParams.envAzureStorageAccountName,
-      envAzureStorageContainerName: appSettingsParams.envAzureStorageContainerName,
-
-      isEnabledPlugins: appSettingsParams.isEnabledPlugins,
       isMaintenanceMode: appSettingsParams.isMaintenanceMode,
 
       // TODO: remove this property when bulk export can be relased for cloud (https://redmine.weseek.co.jp/issues/163220)
       isBulkExportDisabledForCloud: appSettingsParams.isBulkExportDisabledForCloud,
     });
-
-    // if useOnlyEnvVarForFileUploadType is true, get fileUploadType from only env var and make the forms fixed.
-    // and if env var 'FILE_UPLOAD' is null, envFileUploadType is 'aws' that is default value of 'FILE_UPLOAD'.
-    if (appSettingsParams.useOnlyEnvVarForFileUploadType) {
-      this.setState({ fileUploadType: appSettingsParams.envFileUploadType });
-      this.setState({ isFixedFileUploadByEnvVar: true });
-    }
   }
 
   /**
@@ -271,125 +196,6 @@ export default class AdminAppContainer extends Container {
     this.setState({ sesSecretAccessKey });
   }
 
-  /**
-   * Change s3Region
-   */
-  changeS3Region(s3Region) {
-    this.setState({ s3Region });
-  }
-
-  /**
-   * Change s3CustomEndpoint
-   */
-  changeS3CustomEndpoint(s3CustomEndpoint) {
-    this.setState({ s3CustomEndpoint });
-  }
-
-  /**
-   * Change fileUploadType
-   */
-  changeFileUploadType(fileUploadType) {
-    this.setState({ fileUploadType });
-  }
-
-  /**
-   * Change region
-   */
-  changeS3Bucket(s3Bucket) {
-    this.setState({ s3Bucket });
-  }
-
-  /**
-   * Change access key id
-   */
-  changeS3AccessKeyId(s3AccessKeyId) {
-    this.setState({ s3AccessKeyId });
-  }
-
-  /**
-   * Change secret access key
-   */
-  changeS3SecretAccessKey(s3SecretAccessKey) {
-    this.setState({ s3SecretAccessKey });
-  }
-
-  /**
-   * Change s3ReferenceFileWithRelayMode
-   */
-  changeS3ReferenceFileWithRelayMode(s3ReferenceFileWithRelayMode) {
-    this.setState({ s3ReferenceFileWithRelayMode });
-  }
-
-  /**
-   * Change gcsApiKeyJsonPath
-   */
-  changeGcsApiKeyJsonPath(gcsApiKeyJsonPath) {
-    this.setState({ gcsApiKeyJsonPath });
-  }
-
-  /**
-   * Change gcsBucket
-   */
-  changeGcsBucket(gcsBucket) {
-    this.setState({ gcsBucket });
-  }
-
-  /**
-   * Change gcsUploadNamespace
-   */
-  changeGcsUploadNamespace(gcsUploadNamespace) {
-    this.setState({ gcsUploadNamespace });
-  }
-
-  /**
-   * Change gcsReferenceFileWithRelayMode
-   */
-  changeGcsReferenceFileWithRelayMode(gcsReferenceFileWithRelayMode) {
-    this.setState({ gcsReferenceFileWithRelayMode });
-  }
-
-  /**
-   * Change azureReferenceFileWithRelayMode
-   */
-  changeAzureReferenceFileWithRelayMode(azureReferenceFileWithRelayMode) {
-    this.setState({ azureReferenceFileWithRelayMode });
-  }
-
-  /**
-   * Change azureTenantId
-   */
-  changeAzureTenantId(azureTenantId) {
-    this.setState({ azureTenantId });
-  }
-
-  /**
-   * Change azureClientId
-   */
-  changeAzureClientId(azureClientId) {
-    this.setState({ azureClientId });
-  }
-
-  /**
-   * Change azureClientSecret
-   */
-  changeAzureClientSecret(azureClientSecret) {
-    this.setState({ azureClientSecret });
-  }
-
-  /**
-   * Change azureStorageAccountName
-   */
-  changeAzureStorageAccountName(azureStorageAccountName) {
-    this.setState({ azureStorageAccountName });
-  }
-
-  /**
-   * Change azureStorageContainerName
-   */
-  changeAzureStorageContainerName(azureStorageContainerName) {
-    this.setState({ azureStorageContainerName });
-  }
-
   /**
    * Update app setting
    * @memberOf AdminAppContainer
@@ -477,47 +283,6 @@ export default class AdminAppContainer extends Container {
     return apiv3Post('/app-settings/smtp-test');
   }
 
-  /**
-   * Update updateFileUploadSettingHandler
-   * @memberOf AdminAppContainer
-   */
-  async updateFileUploadSettingHandler() {
-    const { fileUploadType } = this.state;
-
-    const requestParams = {
-      fileUploadType,
-    };
-
-    if (fileUploadType === 'gcs') {
-      requestParams.gcsApiKeyJsonPath = this.state.gcsApiKeyJsonPath;
-      requestParams.gcsBucket = this.state.gcsBucket;
-      requestParams.gcsUploadNamespace = this.state.gcsUploadNamespace;
-      requestParams.gcsReferenceFileWithRelayMode = this.state.gcsReferenceFileWithRelayMode;
-    }
-
-    if (fileUploadType === 'aws') {
-      requestParams.s3Region = this.state.s3Region;
-      requestParams.s3CustomEndpoint = this.state.s3CustomEndpoint;
-      requestParams.s3Bucket = this.state.s3Bucket;
-      requestParams.s3AccessKeyId = this.state.s3AccessKeyId;
-      requestParams.s3SecretAccessKey = this.state.s3SecretAccessKey;
-      requestParams.s3ReferenceFileWithRelayMode = this.state.s3ReferenceFileWithRelayMode;
-    }
-
-    if (fileUploadType === 'azure') {
-      requestParams.azureTenantId = this.state.azureTenantId;
-      requestParams.azureClientId = this.state.azureClientId;
-      requestParams.azureClientSecret = this.state.azureClientSecret;
-      requestParams.azureStorageAccountName = this.state.azureStorageAccountName;
-      requestParams.azureStorageContainerName = this.state.azureStorageContainerName;
-      requestParams.azureReferenceFileWithRelayMode = this.state.azureReferenceFileWithRelayMode;
-    }
-
-    const response = await apiv3Put('/app-settings/file-upload-setting', requestParams);
-    const { responseParams } = response.data;
-    return this.setState(responseParams);
-  }
-
   /**
    * Start v5 page migration
    * @memberOf AdminAppContainer

+ 10 - 26
apps/app/src/client/services/AdminImportContainer.js

@@ -73,11 +73,7 @@ export default class AdminImportContainer extends Container {
 
   async esaHandleSubmit() {
     try {
-      const params = {
-        'importer:esa:team_name': this.state.esaTeamName,
-        'importer:esa:access_token': this.state.esaAccessToken,
-      };
-      await apiPost('/admin/import/esa', params);
+      await apiPost('/admin/import/esa');
       toastSuccess('Import posts from esa success.');
     }
     catch (err) {
@@ -88,11 +84,7 @@ export default class AdminImportContainer extends Container {
 
   async esaHandleSubmitTest() {
     try {
-      const params = {
-        'importer:esa:team_name': this.state.esaTeamName,
-        'importer:esa:access_token': this.state.esaAccessToken,
-      };
-      await apiPost('/admin/import/testEsaAPI', params);
+      await apiPost('/admin/import/testEsaAPI');
       toastSuccess('Test connection to esa success.');
     }
     catch (error) {
@@ -100,10 +92,10 @@ export default class AdminImportContainer extends Container {
     }
   }
 
-  async esaHandleSubmitUpdate() {
+  async esaHandleSubmitUpdate(formData) {
     const params = {
-      'importer:esa:team_name': this.state.esaTeamName,
-      'importer:esa:access_token': this.state.esaAccessToken,
+      'importer:esa:team_name': formData.esaTeamName,
+      'importer:esa:access_token': formData.esaAccessToken,
     };
     try {
       await apiPost('/admin/settings/importerEsa', params);
@@ -117,11 +109,7 @@ export default class AdminImportContainer extends Container {
 
   async qiitaHandleSubmit() {
     try {
-      const params = {
-        'importer:qiita:team_name': this.state.qiitaTeamName,
-        'importer:qiita:access_token': this.state.qiitaAccessToken,
-      };
-      await apiPost('/admin/import/qiita', params);
+      await apiPost('/admin/import/qiita');
       toastSuccess('Import posts from qiita:team success.');
     }
     catch (err) {
@@ -133,11 +121,7 @@ export default class AdminImportContainer extends Container {
 
   async qiitaHandleSubmitTest() {
     try {
-      const params = {
-        'importer:qiita:team_name': this.state.qiitaTeamName,
-        'importer:qiita:access_token': this.state.qiitaAccessToken,
-      };
-      await apiPost('/admin/import/testQiitaAPI', params);
+      await apiPost('/admin/import/testQiitaAPI');
       toastSuccess('Test connection to qiita:team success.');
     }
     catch (err) {
@@ -146,10 +130,10 @@ export default class AdminImportContainer extends Container {
     }
   }
 
-  async qiitaHandleSubmitUpdate() {
+  async qiitaHandleSubmitUpdate(formData) {
     const params = {
-      'importer:qiita:team_name': this.state.qiitaTeamName,
-      'importer:qiita:access_token': this.state.qiitaAccessToken,
+      'importer:qiita:team_name': formData.qiitaTeamName,
+      'importer:qiita:access_token': formData.qiitaAccessToken,
     };
     try {
       await apiPost('/admin/settings/importerQiita', params);

+ 3 - 2
apps/app/src/client/services/AdminLdapSecurityContainer.js

@@ -87,9 +87,10 @@ export default class AdminLdapSecurityContainer extends Container {
 
   /**
    * Change ldapBindMode
+   * @param {boolean} isUserBind true: User Bind, false: Admin Bind
    */
-  changeLdapBindMode() {
-    this.setState({ isUserBind: !this.state.isUserBind });
+  changeLdapBindMode(isUserBind) {
+    this.setState({ isUserBind });
   }
 
   /**

+ 4 - 4
apps/app/src/client/services/AdminMarkDownContainer.js

@@ -101,8 +101,8 @@ export default class AdminMarkDownContainer extends Container {
    * Update Xss Setting
    */
   async updateXssSetting() {
-    let { tagWhitelist } = this.state;
-    const { attrWhitelist } = this.state;
+    let { tagWhitelist = '' } = this.state;
+    const { attrWhitelist = '{}' } = this.state;
 
     tagWhitelist = Array.isArray(tagWhitelist) ? tagWhitelist : tagWhitelist.split(',');
 
@@ -111,14 +111,14 @@ export default class AdminMarkDownContainer extends Container {
       JSON.parse(attrWhitelist);
     }
     catch (err) {
-      throw Error(err);
+      throw Error(`attrWhitelist parsing error occured: ${err.message}`);
     }
 
     await apiv3Put('/markdown-setting/xss', {
       isEnabledXss: this.state.isEnabledXss,
       xssOption: this.state.xssOption,
       tagWhitelist,
-      attrWhitelist: attrWhitelist ?? '{}',
+      attrWhitelist,
     });
   }
 

+ 1 - 1
apps/app/src/client/services/user-ui-settings.ts

@@ -1,4 +1,4 @@
-// eslint-disable-next-line no-restricted-imports
+// biome-ignore lint/style/noRestrictedImports: Type-only import from axios
 import type { AxiosResponse } from 'axios';
 import { debounce } from 'throttle-debounce';
 

+ 1 - 3
apps/app/src/client/util/apiv3-client.ts

@@ -1,9 +1,7 @@
-// eslint-disable-next-line no-restricted-imports
+// biome-ignore lint/style/noRestrictedImports: Type-only import from axios
 import type { AxiosResponse } from 'axios';
 import urljoin from 'url-join';
 
-// eslint-disable-next-line no-restricted-imports
-
 import { toArrayIfNot } from '~/utils/array-utils';
 import axios from '~/utils/axios';
 import loggerFactory from '~/utils/logger';

+ 0 - 12
apps/app/src/components/.eslintrc.js

@@ -1,12 +0,0 @@
-module.exports = {
-  extends: '../../.eslintrc.js',
-  rules: {
-    // restrict importing from client/ and components/ directories
-    'no-restricted-imports': ['error', {
-      patterns: [
-        '~/client/',
-        'client/',
-      ],
-    }],
-  },
-};

+ 273 - 94
apps/app/src/components/Admin/Common/AdminNavigation.tsx

@@ -1,37 +1,122 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
+import Link from 'next/link';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 import urljoin from 'url-join';
 
-import { useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '~/stores-universal/context';
+import {
+  useGrowiAppIdForGrowiCloud,
+  useGrowiCloudUri,
+} from '~/stores-universal/context';
 
 import styles from './AdminNavigation.module.scss';
 
 const moduleClass = styles['admin-navigation'];
 
-
 // eslint-disable-next-line react/prop-types
 const MenuLabel = ({ menu }: { menu: string }) => {
   const { t } = useTranslation(['admin', 'commons']);
 
   switch (menu) {
     /* eslint-disable no-multi-spaces, max-len */
-    case 'app':                      return <><span className="material-symbols-outlined me-1">settings</span>{         t('headers.app_settings', { ns: 'commons' }) }</>;
-    case 'security':                 return <><span className="material-symbols-outlined me-1">shield</span>{           t('security_settings.security_settings') }</>;
-    case 'markdown':                 return <><span className="material-symbols-outlined me-1">note</span>{             t('markdown_settings.markdown_settings') }</>;
-    case 'customize':                return <><span className="material-symbols-outlined me-1">construction</span>{     t('customize_settings.customize_settings') }</>;
-    case 'importer':                 return <><span className="material-symbols-outlined me-1">cloud_upload</span>{     t('importer_management.import_data') }</>;
-    case 'export':                   return <><span className="material-symbols-outlined me-1">cloud_download</span>{   t('export_management.export_archive_data') }</>;
-    case 'data-transfer':            return <><span className="material-symbols-outlined me-1">flight</span>{           t('g2g_data_transfer.data_transfer', { ns: 'commons' })}</>;
-    case 'notification':             return <><span className="material-symbols-outlined me-1">notifications</span>{    t('external_notification.external_notification')}</>;
-    case 'slack-integration':        return <><span className="material-symbols-outlined me-1">shuffle</span>{          t('slack_integration.slack_integration') }</>;
-    case 'slack-integration-legacy': return <><span className="material-symbols-outlined me-1">shuffle</span>{          t('slack_integration_legacy.slack_integration_legacy')}</>;
-    case 'users':                    return <><span className="material-symbols-outlined me-1">person</span>{           t('user_management.user_management') }</>;
-    case 'user-groups':              return <><span className="material-symbols-outlined me-1">group</span>{            t('user_group_management.user_group_management') }</>;
-    case 'audit-log':                return <><span className="material-symbols-outlined me-1">feed</span>{             t('audit_log_management.audit_log')}</>;
-    case 'plugins':                  return <><span className="material-symbols-outlined me-1">extension</span>{        t('plugins.plugins')}</>;
+    case 'app':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">settings</span>
+          {t('headers.app_settings', { ns: 'commons' })}
+        </>
+      );
+    case 'security':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">shield</span>
+          {t('security_settings.security_settings')}
+        </>
+      );
+    case 'markdown':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">note</span>
+          {t('markdown_settings.markdown_settings')}
+        </>
+      );
+    case 'customize':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">construction</span>
+          {t('customize_settings.customize_settings')}
+        </>
+      );
+    case 'importer':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">cloud_upload</span>
+          {t('importer_management.import_data')}
+        </>
+      );
+    case 'export':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">cloud_download</span>
+          {t('export_management.export_archive_data')}
+        </>
+      );
+    case 'data-transfer':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">flight</span>
+          {t('g2g_data_transfer.data_transfer', { ns: 'commons' })}
+        </>
+      );
+    case 'notification':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">notifications</span>
+          {t('external_notification.external_notification')}
+        </>
+      );
+    case 'slack-integration':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">shuffle</span>
+          {t('slack_integration.slack_integration')}
+        </>
+      );
+    case 'slack-integration-legacy':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">shuffle</span>
+          {t('slack_integration_legacy.slack_integration_legacy')}
+        </>
+      );
+    case 'users':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">person</span>
+          {t('user_management.user_management')}
+        </>
+      );
+    case 'user-groups':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">group</span>
+          {t('user_group_management.user_group_management')}
+        </>
+      );
+    case 'audit-log':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">feed</span>
+          {t('audit_log_management.audit_log')}
+        </>
+      );
+    case 'plugins':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">extension</span>
+          {t('plugins.plugins')}
+        </>
+      );
     // Temporarily hiding
     // case 'ai-integration':           return (
     //   <>{/* TODO: unify sizing of growi-custom-icons so that simplify code -- 2024.10.09 Yuki Takei */}
@@ -46,24 +131,44 @@ const MenuLabel = ({ menu }: { menu: string }) => {
     //     {t('ai_integration.ai_integration')}
     //   </>
     // );
-    case 'search':                   return <><span className="material-symbols-outlined me-1">search</span>{           t('full_text_search_management.full_text_search_management') }</>;
-    case 'cloud':                    return <><span className="material-symbols-outlined me-1">share</span>{            t('cloud_setting_management.to_cloud_settings')} </>;
-    default:                         return <><span className="material-symbols-outlined me-1">home</span>{             t('wiki_management_homepage') }</>;
-      /* eslint-enable no-multi-spaces, max-len */
+    case 'search':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">search</span>
+          {t('full_text_search_management.full_text_search_management')}
+        </>
+      );
+    case 'cloud':
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">share</span>
+          {t('cloud_setting_management.to_cloud_settings')}{' '}
+        </>
+      );
+    default:
+      return (
+        <>
+          <span className="material-symbols-outlined me-1">home</span>
+          {t('wiki_management_homepage')}
+        </>
+      );
+    /* eslint-enable no-multi-spaces, max-len */
   }
 };
 
 type MenuLinkProps = {
-  menu: string,
-  isListGroupItems: boolean,
-  isRoot?: boolean,
-  isActive?: boolean,
-}
+  menu: string;
+  isListGroupItems: boolean;
+  isRoot?: boolean;
+  isActive?: boolean;
+};
 
 const MenuLink = ({
-  menu, isRoot, isListGroupItems, isActive,
+  menu,
+  isRoot,
+  isListGroupItems,
+  isActive,
 }: MenuLinkProps) => {
-
   const pageTransitionClassName = isListGroupItems
     ? 'list-group-item list-group-item-action rounded border-0'
     : 'dropdown-item px-3 py-2';
@@ -86,57 +191,122 @@ export const AdminNavigation = (): JSX.Element => {
   const { data: growiCloudUri } = useGrowiCloudUri();
   const { data: growiAppIdForGrowiCloud } = useGrowiAppIdForGrowiCloud();
 
-  const isActiveMenu = useCallback((path: string | string[]) => {
-    const paths = Array.isArray(path) ? path : [path];
-
-    return paths.some((path) => {
-      const basisPath = pathUtils.normalizePath(urljoin('/admin', path));
-      const basisParentPath = pathUtils.addTrailingSlash(basisPath);
-
-      return (
-        pathname === basisPath
-        || pathname.startsWith(basisParentPath)
-      );
-    });
-
-  }, [pathname]);
-
-  const getListGroupItemOrDropdownItemList = useCallback((isListGroupItems: boolean) => {
-    return (
-      <>
-        {/* eslint-disable no-multi-spaces */}
-        <MenuLink menu="home" isListGroupItems={isListGroupItems} isActive={pathname === '/admin'} isRoot />
-        <MenuLink menu="app" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/app')} />
-        <MenuLink menu="security" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/security')} />
-        <MenuLink menu="markdown" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/markdown')} />
-        <MenuLink menu="customize" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/customize')} />
-        <MenuLink menu="importer" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/importer')} />
-        <MenuLink menu="export" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/export')} />
-        <MenuLink menu="data-transfer" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/data-transfer')} />
-        <MenuLink menu="notification" isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/notification', '/global-notification'])} />
-        <MenuLink menu="slack-integration" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/slack-integration')} />
-        <MenuLink menu="slack-integration-legacy" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/slack-integration-legacy')} />
-        <MenuLink menu="users" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/users')} />
-        <MenuLink menu="user-groups" isListGroupItems={isListGroupItems} isActive={isActiveMenu(['/user-groups', 'user-group-detail'])} />
-        <MenuLink menu="audit-log" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/audit-log')} />
-        <MenuLink menu="plugins" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/plugins')} />
-        {/* Temporarily hiding */}
-        {/* <MenuLink menu="ai-integration" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/aai-integration')} /> */}
-        <MenuLink menu="search" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/search')} />
-        {growiCloudUri != null && growiAppIdForGrowiCloud != null
-          && (
+  const isActiveMenu = useCallback(
+    (path: string | string[]) => {
+      const paths = Array.isArray(path) ? path : [path];
+
+      return paths.some((path) => {
+        const basisPath = pathUtils.normalizePath(urljoin('/admin', path));
+        const basisParentPath = pathUtils.addTrailingSlash(basisPath);
+
+        return pathname === basisPath || pathname.startsWith(basisParentPath);
+      });
+    },
+    [pathname],
+  );
+
+  const getListGroupItemOrDropdownItemList = useCallback(
+    (isListGroupItems: boolean) => {
+      return (
+        <>
+          {/* eslint-disable no-multi-spaces */}
+          <MenuLink
+            menu="home"
+            isListGroupItems={isListGroupItems}
+            isActive={pathname === '/admin'}
+            isRoot
+          />
+          <MenuLink
+            menu="app"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/app')}
+          />
+          <MenuLink
+            menu="security"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/security')}
+          />
+          <MenuLink
+            menu="markdown"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/markdown')}
+          />
+          <MenuLink
+            menu="customize"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/customize')}
+          />
+          <MenuLink
+            menu="importer"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/importer')}
+          />
+          <MenuLink
+            menu="export"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/export')}
+          />
+          <MenuLink
+            menu="data-transfer"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/data-transfer')}
+          />
+          <MenuLink
+            menu="notification"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu(['/notification', '/global-notification'])}
+          />
+          <MenuLink
+            menu="slack-integration"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/slack-integration')}
+          />
+          <MenuLink
+            menu="slack-integration-legacy"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/slack-integration-legacy')}
+          />
+          <MenuLink
+            menu="users"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/users')}
+          />
+          <MenuLink
+            menu="user-groups"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu(['/user-groups', 'user-group-detail'])}
+          />
+          <MenuLink
+            menu="audit-log"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/audit-log')}
+          />
+          <MenuLink
+            menu="plugins"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/plugins')}
+          />
+          {/* Temporarily hiding */}
+          {/* <MenuLink menu="ai-integration" isListGroupItems={isListGroupItems} isActive={isActiveMenu('/aai-integration')} /> */}
+          <MenuLink
+            menu="search"
+            isListGroupItems={isListGroupItems}
+            isActive={isActiveMenu('/search')}
+          />
+          {growiCloudUri != null && growiAppIdForGrowiCloud != null && (
             <a
               href={`${growiCloudUri}/my/apps/${growiAppIdForGrowiCloud}`}
               className="list-group-item list-group-item-action border-0 round-corner"
             >
               <MenuLabel menu="cloud" />
             </a>
-          )
-        }
-        {/* eslint-enable no-multi-spaces */}
-      </>
-    );
-  }, [growiAppIdForGrowiCloud, growiCloudUri, isActiveMenu, pathname]);
+          )}
+          {/* eslint-enable no-multi-spaces */}
+        </>
+      );
+    },
+    [growiAppIdForGrowiCloud, growiCloudUri, isActiveMenu, pathname],
+  );
 
   return (
     <React.Fragment>
@@ -158,33 +328,42 @@ export const AdminNavigation = (): JSX.Element => {
         >
           <span className="float-start">
             {/* eslint-disable no-multi-spaces */}
-            {pathname === '/admin'                  && <MenuLabel menu="home" />}
-            {isActiveMenu('/app')                   && <MenuLabel menu="app" />}
-            {isActiveMenu('/security')              && <MenuLabel menu="security" />}
-            {isActiveMenu('/markdown')              && <MenuLabel menu="markdown" />}
-            {isActiveMenu('/customize')             && <MenuLabel menu="customize" />}
-            {isActiveMenu('/importer')              && <MenuLabel menu="importer" />}
-            {isActiveMenu('/export')                && <MenuLabel menu="export" />}
-            {(isActiveMenu(['/notification', '/global-notification']))
-                                                    && <MenuLabel menu="notification" />}
-            {isActiveMenu('/slack-integration')     && <MenuLabel menu="slack-integration" />}
-            {isActiveMenu('/users')                 && <MenuLabel menu="users" />}
-            {isActiveMenu(['/user-groups', 'user-group-detail'])
-                                                    && <MenuLabel menu="user-groups" />}
-            {isActiveMenu('/search')                && <MenuLabel menu="search" />}
-            {isActiveMenu('/audit-log')             && <MenuLabel menu="audit-log" />}
-            {isActiveMenu('/plugins')               && <MenuLabel menu="plugins" />}
-            {isActiveMenu('/data-transfer')         && <MenuLabel menu="data-transfer" />}
+            {pathname === '/admin' && <MenuLabel menu="home" />}
+            {isActiveMenu('/app') && <MenuLabel menu="app" />}
+            {isActiveMenu('/security') && <MenuLabel menu="security" />}
+            {isActiveMenu('/markdown') && <MenuLabel menu="markdown" />}
+            {isActiveMenu('/customize') && <MenuLabel menu="customize" />}
+            {isActiveMenu('/importer') && <MenuLabel menu="importer" />}
+            {isActiveMenu('/export') && <MenuLabel menu="export" />}
+            {isActiveMenu(['/notification', '/global-notification']) && (
+              <MenuLabel menu="notification" />
+            )}
+            {isActiveMenu('/slack-integration') && (
+              <MenuLabel menu="slack-integration" />
+            )}
+            {isActiveMenu('/users') && <MenuLabel menu="users" />}
+            {isActiveMenu(['/user-groups', 'user-group-detail']) && (
+              <MenuLabel menu="user-groups" />
+            )}
+            {isActiveMenu('/search') && <MenuLabel menu="search" />}
+            {isActiveMenu('/audit-log') && <MenuLabel menu="audit-log" />}
+            {isActiveMenu('/plugins') && <MenuLabel menu="plugins" />}
+            {isActiveMenu('/data-transfer') && (
+              <MenuLabel menu="data-transfer" />
+            )}
             {/* Temporarily hiding */}
             {/* {isActiveMenu('/ai-integration')                && <MenuLabel menu="ai-integration" />} */}
             {/* eslint-enable no-multi-spaces */}
           </span>
         </button>
-        <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
+        <div
+          className="dropdown-menu"
+          role="menu"
+          aria-labelledby="dropdown-admin-navigation"
+        >
           {getListGroupItemOrDropdownItemList(false)}
         </div>
       </div>
-
     </React.Fragment>
   );
 };

+ 1 - 0
apps/app/src/components/Common/GrowiLogo.jsx

@@ -12,6 +12,7 @@ const GrowiLogo = memo(() => (
       height="32"
       viewBox="0 0 64 56"
     >
+      <title>GROWI</title>
       <path
         // eslint-disable-next-line max-len
         d="M17.123 33.8015L10.4717 45.3855C10.2686 45.7427 10.2686 46.1829 10.4717 46.5337L15.5934 55.4514C15.7838 55.7767 16.171 55.9999 16.5645 55.9999H17.123L23.5014 44.9007L17.123 33.8015Z"

+ 97 - 73
apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx

@@ -1,5 +1,4 @@
-import React, { memo, useCallback, type JSX } from 'react';
-
+import React, { type JSX, memo, useCallback } from 'react';
 import Link from 'next/link';
 import urljoin from 'url-join';
 
@@ -7,94 +6,119 @@ import type LinkedPagePath from '~/models/linked-page-path';
 
 import styles from './PagePathHierarchicalLink.module.scss';
 
-
 type PagePathHierarchicalLinkProps = {
-  linkedPagePath: LinkedPagePath,
-  linkedPagePathByHtml?: LinkedPagePath,
-  basePath?: string,
-  isInTrash?: boolean,
-  isIconHidden?: boolean,
+  linkedPagePath: LinkedPagePath;
+  linkedPagePathByHtml?: LinkedPagePath;
+  basePath?: string;
+  isInTrash?: boolean;
+  isIconHidden?: boolean;
 
   // !!INTERNAL USE ONLY!!
-  isInnerElem?: boolean,
+  isInnerElem?: boolean;
 };
 
-export const PagePathHierarchicalLink = memo((props: PagePathHierarchicalLinkProps): JSX.Element => {
-  const {
-    linkedPagePath, linkedPagePathByHtml, basePath, isInTrash, isInnerElem,
-  } = props;
-
-  const isIconHidden = props.isIconHidden ?? false;
-
-  // eslint-disable-next-line react/prop-types
-  const RootElm = useCallback(({ children }) => {
-    return isInnerElem
-      ? <>{children}</>
-      : <span className="text-break" id="grw-page-path-hierarchical-link">{children}</span>;
-  }, [isInnerElem]);
-
-  // render root element
-  if (linkedPagePath.isRoot) {
-    if (basePath != null || isIconHidden) {
-      return <></>;
-    }
-
-    return isInTrash
-      ? (
+export const PagePathHierarchicalLink = memo(
+  (props: PagePathHierarchicalLinkProps): JSX.Element => {
+    const {
+      linkedPagePath,
+      linkedPagePathByHtml,
+      basePath,
+      isInTrash,
+      isInnerElem,
+    } = props;
+
+    const isIconHidden = props.isIconHidden ?? false;
+
+    // eslint-disable-next-line react/prop-types
+    const RootElm = useCallback(
+      ({ children }) => {
+        return isInnerElem ? (
+          <>{children}</>
+        ) : (
+          <span className="text-break" id="grw-page-path-hierarchical-link">
+            {children}
+          </span>
+        );
+      },
+      [isInnerElem],
+    );
+
+    // render root element
+    if (linkedPagePath.isRoot) {
+      if (basePath != null || isIconHidden) {
+        return <></>;
+      }
+
+      return isInTrash ? (
         <RootElm>
           <span className="path-segment">
             <Link href="/trash" prefetch={false}>
-              <span className={`material-symbols-outlined ${styles['material-symbols-outlined']}`}>delete</span>
+              <span
+                className={`material-symbols-outlined ${styles['material-symbols-outlined']}`}
+              >
+                delete
+              </span>
             </Link>
           </span>
-          <span className={`separator ${styles.separator}`}><a href="/">/</a></span>
+          <span className={`separator ${styles.separator}`}>
+            <a href="/">/</a>
+          </span>
         </RootElm>
-      )
-      : (
+      ) : (
         <RootElm>
           <span className="path-segment">
             <Link href="/" prefetch={false}>
-              <span className={`material-symbols-outlined ${styles['material-symbols-outlined']}`}>home</span>
+              <span
+                className={`material-symbols-outlined ${styles['material-symbols-outlined']}`}
+              >
+                home
+              </span>
               <span className={`separator ${styles.separator}`}>/</span>
             </Link>
           </span>
         </RootElm>
       );
-  }
-
-  const isParentExists = linkedPagePath.parent != null;
-  const isParentRoot = linkedPagePath.parent?.isRoot;
-  const isSeparatorRequired = isParentExists && !isParentRoot;
-
-  const shouldDangerouslySetInnerHTML = linkedPagePathByHtml != null;
-
-  const href = encodeURI(urljoin(basePath || '/', linkedPagePath.href));
-
-  return (
-    <RootElm>
-      { isParentExists && (
-        <PagePathHierarchicalLink
-          linkedPagePath={linkedPagePath.parent}
-          linkedPagePathByHtml={linkedPagePathByHtml?.parent}
-          basePath={basePath}
-          isInTrash={isInTrash || linkedPagePath.isInTrash}
-          isInnerElem
-          isIconHidden={isIconHidden}
-        />
-      ) }
-      { isSeparatorRequired && (
-        <span className={`separator ${styles.separator}`}>/</span>
-      ) }
-
-      <Link href={href} prefetch={false} legacyBehavior>
-        {
-          shouldDangerouslySetInnerHTML
-            // eslint-disable-next-line react/no-danger
-            ? <a className="page-segment" dangerouslySetInnerHTML={{ __html: linkedPagePathByHtml.pathName }}></a>
-            : <a className="page-segment">{linkedPagePath.pathName}</a>
-        }
-      </Link>
+    }
 
-    </RootElm>
-  );
-});
+    const isParentExists = linkedPagePath.parent != null;
+    const isParentRoot = linkedPagePath.parent?.isRoot;
+    const isSeparatorRequired = isParentExists && !isParentRoot;
+
+    const shouldDangerouslySetInnerHTML = linkedPagePathByHtml != null;
+
+    const href = encodeURI(urljoin(basePath || '/', linkedPagePath.href));
+
+    return (
+      <RootElm>
+        {isParentExists && (
+          <PagePathHierarchicalLink
+            linkedPagePath={linkedPagePath.parent}
+            linkedPagePathByHtml={linkedPagePathByHtml?.parent}
+            basePath={basePath}
+            isInTrash={isInTrash || linkedPagePath.isInTrash}
+            isInnerElem
+            isIconHidden={isIconHidden}
+          />
+        )}
+        {isSeparatorRequired && (
+          <span className={`separator ${styles.separator}`}>/</span>
+        )}
+        <Link href={href} prefetch={false} legacyBehavior>
+          {shouldDangerouslySetInnerHTML ? (
+            // biome-ignore-start lint/a11y/useValidAnchor: ignore
+            <a
+              className="page-segment"
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+              dangerouslySetInnerHTML={{
+                __html: linkedPagePathByHtml.pathName,
+              }}
+            ></a>
+          ) : (
+            <a className="page-segment">{linkedPagePath.pathName}</a>
+            // biome-ignore-end lint/a11y/useValidAnchor: ignore
+          )}
+        </Link>
+      </RootElm>
+    );
+  },
+);

+ 22 - 10
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -1,23 +1,23 @@
-import { useMemo, type JSX } from 'react';
-
+import { type JSX, useMemo } from 'react';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
 
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { PagePathHierarchicalLink } from '../PagePathHierarchicalLink';
-
 import type { PagePathNavLayoutProps } from './PagePathNavLayout';
 import { PagePathNavLayout } from './PagePathNavLayout';
 
 import styles from './PagePathNav.module.scss';
 
-
 const { isTrashPage } = pagePathUtils;
 
-
-const Separator = ({ className }: {className?: string}): JSX.Element => {
-  return <span className={`separator ${className ?? ''} ${styles['grw-mx-02em']}`}>/</span>;
+const Separator = ({ className }: { className?: string }): JSX.Element => {
+  return (
+    <span className={`separator ${className ?? ''} ${styles['grw-mx-02em']}`}>
+      /
+    </span>
+  );
 };
 
 export const PagePathNav = (props: PagePathNavLayoutProps): JSX.Element => {
@@ -37,7 +37,10 @@ export const PagePathNav = (props: PagePathNavLayoutProps): JSX.Element => {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     return (
       <>
-        <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />
+        <PagePathHierarchicalLink
+          linkedPagePath={linkedPagePathFormer}
+          isInTrash={isInTrash}
+        />
         <Separator />
       </>
     );
@@ -49,13 +52,22 @@ export const PagePathNav = (props: PagePathNavLayoutProps): JSX.Element => {
     // one line
     if (dPagePath.isRoot || dPagePath.isFormerRoot) {
       const linkedPagePath = new LinkedPagePath(pagePath);
-      return <PagePathHierarchicalLink linkedPagePath={linkedPagePath} isInTrash={isInTrash} />;
+      return (
+        <PagePathHierarchicalLink
+          linkedPagePath={linkedPagePath}
+          isInTrash={isInTrash}
+        />
+      );
     }
 
     // two line
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
     return (
-      <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} isInTrash={isInTrash} />
+      <PagePathHierarchicalLink
+        linkedPagePath={linkedPagePathLatter}
+        basePath={dPagePath.former}
+        isInTrash={isInTrash}
+      />
     );
   }, [isInTrash, pagePath]);
 

+ 30 - 26
apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx

@@ -1,5 +1,4 @@
-import type { ReactNode, JSX } from 'react';
-
+import type { JSX, ReactNode } from 'react';
 import dynamic from 'next/dynamic';
 
 import { useIsNotFound } from '~/stores/page';
@@ -9,28 +8,36 @@ import styles from './PagePathNav.module.scss';
 const moduleClass = styles['grw-page-path-nav-layout'] ?? '';
 
 export type PagePathNavLayoutProps = {
-  pagePath: string,
-  inline?: boolean,
-  className?: string,
-  pageId?: string | null,
-  isWipPage?: boolean,
-  maxWidth?: number,
-  formerLinkClassName?: string,
-  latterLinkClassName?: string,
-}
+  pagePath: string;
+  inline?: boolean;
+  className?: string;
+  pageId?: string | null;
+  isWipPage?: boolean;
+  maxWidth?: number;
+  formerLinkClassName?: string;
+  latterLinkClassName?: string;
+};
 
 type Props = PagePathNavLayoutProps & {
-  formerLink?: ReactNode,
-  latterLink?: ReactNode,
-}
+  formerLink?: ReactNode;
+  latterLink?: ReactNode;
+};
 
-const CopyDropdown = dynamic(() => import('~/client/components/Common/CopyDropdown').then(mod => mod.CopyDropdown), { ssr: false });
+const CopyDropdown = dynamic(
+  () =>
+    import('~/client/components/Common/CopyDropdown').then(
+      (mod) => mod.CopyDropdown,
+    ),
+  { ssr: false },
+);
 
 export const PagePathNavLayout = (props: Props): JSX.Element => {
   const {
     className = '',
     inline = false,
-    pageId, pagePath, isWipPage,
+    pageId,
+    pagePath,
+    isWipPage,
     formerLink,
     formerLinkClassName = '',
     latterLink,
@@ -45,12 +52,11 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
   const containerLayoutClass = inline ? '' : 'd-flex align-items-center';
 
   return (
-    <div
-      className={`${className} ${moduleClass}`}
-      style={{ maxWidth }}
-    >
+    <div className={`${className} ${moduleClass}`} style={{ maxWidth }}>
       {formerLink && (
-        <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']} mb-2 d-block`}>
+        <span
+          className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']} mb-2 d-block`}
+        >
           {formerLink}
         </span>
       )}
@@ -58,11 +64,9 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
         <h1 className={`m-0 d-inline align-bottom ${latterLinkClassName}`}>
           {latterLink}
         </h1>
-        { pageId != null && !isNotFound && (
+        {pageId != null && !isNotFound && (
           <span className="d-inline-flex align-items-center align-bottom ms-2 gap-2">
-            { isWipPage && (
-              <span className="badge text-bg-secondary">WIP</span>
-            )}
+            {isWipPage && <span className="badge text-bg-secondary">WIP</span>}
             <span className="grw-page-path-nav-copydropdown">
               <CopyDropdown
                 pageId={pageId}
@@ -75,7 +79,7 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
               </CopyDropdown>
             </span>
           </span>
-        ) }
+        )}
       </div>
     </div>
   );

+ 8 - 3
apps/app/src/components/Common/PagePathNav/Separator.tsx

@@ -2,7 +2,12 @@ import type { JSX } from 'react';
 
 import styles from './Separator.module.scss';
 
-
-export const Separator = ({ className }: {className?: string}): JSX.Element => (
-  <span className={`separator ${className ?? ''} ${styles['grw-mx-02em']}`}>/</span>
+export const Separator = ({
+  className,
+}: {
+  className?: string;
+}): JSX.Element => (
+  <span className={`separator ${className ?? ''} ${styles['grw-mx-02em']}`}>
+    /
+  </span>
 );

+ 36 - 22
apps/app/src/components/Common/PagePathNavTitle/PagePathNavTitle.tsx

@@ -1,43 +1,57 @@
-import { useState, type JSX } from 'react';
-
-import withLoadingProps from 'next-dynamic-loading-props';
+import { type JSX, useState } from 'react';
 import dynamic from 'next/dynamic';
+import withLoadingProps from 'next-dynamic-loading-props';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
-import { PagePathNav } from '../PagePathNav';
 import type { PagePathNavLayoutProps } from '../PagePathNav';
+import { PagePathNav } from '../PagePathNav';
 
 import styles from './PagePathNavTitle.module.scss';
 
 const moduleClass = styles['grw-page-path-nav-title'] ?? '';
 
-
-const PagePathNavSticky = withLoadingProps<PagePathNavLayoutProps>(useLoadingProps => dynamic(
-  () => import('~/client/components/PagePathNavSticky').then(mod => mod.PagePathNavSticky),
-  {
-    ssr: false,
-    loading: () => {
-      // eslint-disable-next-line react-hooks/rules-of-hooks
-      const props = useLoadingProps();
-      return <PagePathNav {...props} />;
-    },
-  },
-));
+const PagePathNavSticky = withLoadingProps<PagePathNavLayoutProps>(
+  (useLoadingProps) =>
+    dynamic(
+      () =>
+        import('~/client/components/PagePathNavSticky').then(
+          (mod) => mod.PagePathNavSticky,
+        ),
+      {
+        ssr: false,
+        loading: () => {
+          // eslint-disable-next-line react-hooks/rules-of-hooks
+          const props = useLoadingProps();
+          return <PagePathNav {...props} />;
+        },
+      },
+    ),
+);
 
 /**
  * Switch PagePathNav and PagePathNavSticky
  * @returns
  */
-export const PagePathNavTitle = (props: PagePathNavLayoutProps): JSX.Element => {
-
+export const PagePathNavTitle = (
+  props: PagePathNavLayoutProps,
+): JSX.Element => {
   const [isClient, setClient] = useState(false);
 
   useIsomorphicLayoutEffect(() => {
     setClient(true);
   }, []);
 
-  return isClient
-    ? <PagePathNavSticky {...props} className={moduleClass} latterLinkClassName="fs-2" />
-    : <PagePathNav {...props} className={moduleClass} latterLinkClassName="fs-2" />;
-
+  return isClient ? (
+    <PagePathNavSticky
+      {...props}
+      className={moduleClass}
+      latterLinkClassName="fs-2"
+    />
+  ) : (
+    <PagePathNav
+      {...props}
+      className={moduleClass}
+      latterLinkClassName="fs-2"
+    />
+  );
 };

+ 1 - 1
apps/app/src/components/FontFamily/GlobalFonts.tsx

@@ -1,4 +1,4 @@
-import { memo, type JSX } from 'react';
+import { type JSX, memo } from 'react';
 
 import { useGrowiCustomIcon } from './use-growi-custom-icons';
 import { useLatoFontFamily } from './use-lato';

+ 25 - 22
apps/app/src/components/Layout/AdminLayout.tsx

@@ -1,6 +1,5 @@
-import type { ReactNode, JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 import React from 'react';
-
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 
@@ -8,30 +7,37 @@ import GrowiLogo from '~/components/Common/GrowiLogo';
 
 import { RawLayout } from './RawLayout';
 
-
 import styles from './Admin.module.scss';
 
-
-const AdminNavigation = dynamic(() => import('../Admin/Common/AdminNavigation').then(mod => mod.AdminNavigation), { ssr: false });
-const PageCreateModal = dynamic(() => import('~/client/components/PageCreateModal'), { ssr: false });
-const SystemVersion = dynamic(() => import('~/client/components/SystemVersion'), { ssr: false });
-const HotkeysManager = dynamic(() => import('~/client/components/Hotkeys/HotkeysManager'), { ssr: false });
-
+const AdminNavigation = dynamic(
+  () =>
+    import('../Admin/Common/AdminNavigation').then(
+      (mod) => mod.AdminNavigation,
+    ),
+  { ssr: false },
+);
+const PageCreateModal = dynamic(
+  () => import('~/client/components/PageCreateModal'),
+  { ssr: false },
+);
+const SystemVersion = dynamic(
+  () => import('~/client/components/SystemVersion'),
+  { ssr: false },
+);
+const HotkeysManager = dynamic(
+  () => import('~/client/components/Hotkeys/HotkeysManager'),
+  { ssr: false },
+);
 
 type Props = {
-  componentTitle?: string
-  children?: ReactNode
-}
-
-
-const AdminLayout = ({
-  children, componentTitle,
-}: Props): JSX.Element => {
+  componentTitle?: string;
+  children?: ReactNode;
+};
 
+const AdminLayout = ({ children, componentTitle }: Props): JSX.Element => {
   return (
     <RawLayout>
       <div className={`admin-page ${styles['admin-page']}`}>
-
         <header className="py-0 container">
           <h1 className="p-3 fs-2 d-flex align-items-center">
             <Link href="/" className="d-block mb-1 me-2">
@@ -46,9 +52,7 @@ const AdminLayout = ({
               <div className="col-lg-3">
                 <AdminNavigation />
               </div>
-              <div className="col-lg-9 mb-5">
-                {children}
-              </div>
+              <div className="col-lg-9 mb-5">{children}</div>
             </div>
           </div>
         </div>
@@ -58,7 +62,6 @@ const AdminLayout = ({
       </div>
 
       <HotkeysManager />
-
     </RawLayout>
   );
 };

+ 109 - 33
apps/app/src/components/Layout/BasicLayout.tsx

@@ -1,58 +1,133 @@
-import type { ReactNode, JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 import React from 'react';
-
 import dynamic from 'next/dynamic';
 
 import { RawLayout } from './RawLayout';
 
-
 import styles from './BasicLayout.module.scss';
 
 const AiAssistantSidebar = dynamic(
-  () => import('~/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar')
-    .then(mod => mod.AiAssistantSidebar), { ssr: false },
+  () =>
+    import(
+      '~/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar'
+    ).then((mod) => mod.AiAssistantSidebar),
+  { ssr: false },
 );
 
-
 const moduleClass = styles['grw-basic-layout'] ?? '';
 
+const Sidebar = dynamic(
+  () => import('~/client/components/Sidebar').then((mod) => mod.Sidebar),
+  { ssr: false },
+);
 
-const Sidebar = dynamic(() => import('~/client/components/Sidebar').then(mod => mod.Sidebar), { ssr: false });
-
-const AlertSiteUrlUndefined = dynamic(() => import('~/client/components/AlertSiteUrlUndefined').then(mod => mod.AlertSiteUrlUndefined), { ssr: false });
+const AlertSiteUrlUndefined = dynamic(
+  () =>
+    import('~/client/components/AlertSiteUrlUndefined').then(
+      (mod) => mod.AlertSiteUrlUndefined,
+    ),
+  { ssr: false },
+);
 const DeleteAttachmentModal = dynamic(
-  () => import('~/client/components/PageAttachment/DeleteAttachmentModal').then(mod => mod.DeleteAttachmentModal), { ssr: false },
+  () =>
+    import('~/client/components/PageAttachment/DeleteAttachmentModal').then(
+      (mod) => mod.DeleteAttachmentModal,
+    ),
+  { ssr: false },
+);
+const HotkeysManager = dynamic(
+  () => import('~/client/components/Hotkeys/HotkeysManager'),
+  { ssr: false },
+);
+const GrowiNavbarBottom = dynamic(
+  () =>
+    import('~/client/components/Navbar/GrowiNavbarBottom').then(
+      (mod) => mod.GrowiNavbarBottom,
+    ),
+  { ssr: false },
+);
+const ShortcutsModal = dynamic(
+  () => import('~/client/components/ShortcutsModal'),
+  { ssr: false },
+);
+const SystemVersion = dynamic(
+  () => import('~/client/components/SystemVersion'),
+  { ssr: false },
+);
+const PutbackPageModal = dynamic(
+  () => import('~/client/components/PutbackPageModal'),
+  { ssr: false },
 );
-const HotkeysManager = dynamic(() => import('~/client/components/Hotkeys/HotkeysManager'), { ssr: false });
-const GrowiNavbarBottom = dynamic(() => import('~/client/components/Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
-const ShortcutsModal = dynamic(() => import('~/client/components/ShortcutsModal'), { ssr: false });
-const SystemVersion = dynamic(() => import('~/client/components/SystemVersion'), { ssr: false });
-const PutbackPageModal = dynamic(() => import('~/client/components/PutbackPageModal'), { ssr: false });
 // Page modals
-const PageCreateModal = dynamic(() => import('~/client/components/PageCreateModal'), { ssr: false });
-const PageDuplicateModal = dynamic(() => import('~/client/components/PageDuplicateModal'), { ssr: false });
-const PageDeleteModal = dynamic(() => import('~/client/components/PageDeleteModal'), { ssr: false });
-const PageRenameModal = dynamic(() => import('~/client/components/PageRenameModal'), { ssr: false });
-const PagePresentationModal = dynamic(() => import('~/client/components/PagePresentationModal'), { ssr: false });
-const PageAccessoriesModal = dynamic(() => import('~/client/components/PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
-const GrantedGroupsInheritanceSelectModal = dynamic(() => import('~/client/components/GrantedGroupsInheritanceSelectModal'), { ssr: false });
+const PageCreateModal = dynamic(
+  () => import('~/client/components/PageCreateModal'),
+  { ssr: false },
+);
+const PageDuplicateModal = dynamic(
+  () => import('~/client/components/PageDuplicateModal'),
+  { ssr: false },
+);
+const PageDeleteModal = dynamic(
+  () => import('~/client/components/PageDeleteModal'),
+  { ssr: false },
+);
+const PageRenameModal = dynamic(
+  () => import('~/client/components/PageRenameModal'),
+  { ssr: false },
+);
+const PagePresentationModal = dynamic(
+  () => import('~/client/components/PagePresentationModal'),
+  { ssr: false },
+);
+const PageAccessoriesModal = dynamic(
+  () =>
+    import('~/client/components/PageAccessoriesModal').then(
+      (mod) => mod.PageAccessoriesModal,
+    ),
+  { ssr: false },
+);
+const GrantedGroupsInheritanceSelectModal = dynamic(
+  () => import('~/client/components/GrantedGroupsInheritanceSelectModal'),
+  { ssr: false },
+);
 const DeleteBookmarkFolderModal = dynamic(
-  () => import('~/client/components/DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false },
+  () =>
+    import('~/client/components/DeleteBookmarkFolderModal').then(
+      (mod) => mod.DeleteBookmarkFolderModal,
+    ),
+  { ssr: false },
+);
+const SearchModal = dynamic(
+  () => import('../../features/search/client/components/SearchModal'),
+  { ssr: false },
+);
+const PageBulkExportSelectModal = dynamic(
+  () =>
+    import(
+      '../../features/page-bulk-export/client/components/PageBulkExportSelectModal'
+    ),
+  { ssr: false },
 );
-const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
-const PageBulkExportSelectModal = dynamic(() => import('../../features/page-bulk-export/client/components/PageBulkExportSelectModal'), { ssr: false });
 
 const AiAssistantManagementModal = dynamic(
-  () => import('~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal')
-    .then(mod => mod.AiAssistantManagementModal), { ssr: false },
+  () =>
+    import(
+      '~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal'
+    ).then((mod) => mod.AiAssistantManagementModal),
+  { ssr: false },
+);
+const PageSelectModal = dynamic(
+  () =>
+    import('~/client/components/PageSelectModal/PageSelectModal').then(
+      (mod) => mod.PageSelectModal,
+    ),
+  { ssr: false },
 );
-const PageSelectModal = dynamic(() => import('~/client/components/PageSelectModal/PageSelectModal').then(mod => mod.PageSelectModal), { ssr: false });
 
 type Props = {
-  children?: ReactNode
-  className?: string
-}
-
+  children?: ReactNode;
+  className?: string;
+};
 
 export const BasicLayout = ({ children, className }: Props): JSX.Element => {
   return (
@@ -62,7 +137,8 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
           <Sidebar />
         </div>
 
-        <div className="d-flex flex-grow-1 flex-column mw-0 z-1">{/* neccessary for nested {children} make expanded */}
+        <div className="d-flex flex-grow-1 flex-column mw-0 z-1">
+          {/* neccessary for nested {children} make expanded */}
           <AlertSiteUrlUndefined />
           {children}
         </div>

+ 13 - 16
apps/app/src/components/Layout/NoLoginLayout.tsx

@@ -1,26 +1,20 @@
-import type { ReactNode, JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 import React from 'react';
-
 import Image from 'next/image';
 
 import { useAppTitle } from '~/stores-universal/context';
 
 import GrowiLogo from '../Common/GrowiLogo';
-
 import { RawLayout } from './RawLayout';
 
-
 import commonStyles from './NoLoginLayout.module.scss';
 
 type Props = {
-  className?: string,
-  children?: ReactNode,
-}
-
-export const NoLoginLayout = ({
-  children, className,
-}: Props): JSX.Element => {
+  className?: string;
+  children?: ReactNode;
+};
 
+export const NoLoginLayout = ({ children, className }: Props): JSX.Element => {
   const { data: appTitle } = useAppTitle();
 
   const classNames: string[] = [''];
@@ -32,23 +26,26 @@ export const NoLoginLayout = ({
     <RawLayout className={`nologin ${commonStyles.nologin} ${classNames}`}>
       <div className="d-flex align-items-center vh-100 mt-0 flex-row">
         <div className="main container-fluid">
-
           <div className="row">
-
             <div className="col-md-12 position-relative">
               <div className="nologin-header mx-auto rounded-4 rounded-bottom-0">
                 <div className="d-flex justify-content-center align-items-center">
                   <GrowiLogo />
-                  <Image width={128.48} height={28} src="/images/growi-brand-logo-login.svg" alt="GROWI" className="growi-logo-type my-3" />
+                  <Image
+                    width={128.48}
+                    height={28}
+                    src="/images/growi-brand-logo-login.svg"
+                    alt="GROWI"
+                    className="growi-logo-type my-3"
+                  />
                 </div>
                 {appTitle !== 'GROWI' ? (
-                  <h2 className="fs-4 text-center text-white">{ appTitle }</h2>
+                  <h2 className="fs-4 text-center text-white">{appTitle}</h2>
                 ) : null}
                 <div className="noLogin-form-errors px-3"></div>
               </div>
               {children}
             </div>
-
           </div>
         </div>
       </div>

+ 16 - 11
apps/app/src/components/Layout/RawLayout.tsx

@@ -1,12 +1,14 @@
-import type { ReactNode, JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 import React, { useState } from 'react';
-
-import type { ColorScheme } from '@growi/core';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
+import type { ColorScheme } from '@growi/core';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
-import { useNextThemes, NextThemesProvider } from '~/stores-universal/use-next-themes';
+import {
+  NextThemesProvider,
+  useNextThemes,
+} from '~/stores-universal/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
 import styles from './RawLayout.module.scss';
@@ -15,14 +17,15 @@ const toastContainerClass = styles['grw-toast-container'] ?? '';
 
 const logger = loggerFactory('growi:cli:RawLayout');
 
-
-const ToastContainer = dynamic(() => import('react-toastify').then(mod => mod.ToastContainer), { ssr: false });
-
+const ToastContainer = dynamic(
+  () => import('react-toastify').then((mod) => mod.ToastContainer),
+  { ssr: false },
+);
 
 type Props = {
-  className?: string,
-  children?: ReactNode,
-}
+  className?: string;
+  children?: ReactNode;
+};
 
 export const RawLayout = ({ children, className }: Props): JSX.Element => {
   const classNames: string[] = ['layout-root', 'growi'];
@@ -32,7 +35,9 @@ export const RawLayout = ({ children, className }: Props): JSX.Element => {
   // get color scheme from next-themes
   const { resolvedTheme, resolvedThemeByAttributes } = useNextThemes();
 
-  const [colorScheme, setColorScheme] = useState<ColorScheme|undefined>(undefined);
+  const [colorScheme, setColorScheme] = useState<ColorScheme | undefined>(
+    undefined,
+  );
 
   // set colorScheme in CSR
   useIsomorphicLayoutEffect(() => {

+ 4 - 5
apps/app/src/components/Layout/SearchResultLayout.tsx

@@ -1,18 +1,17 @@
-import React, { type ReactNode, type JSX } from 'react';
+import React, { type JSX, type ReactNode } from 'react';
 
 import { BasicLayout } from './BasicLayout';
 
 import commonStyles from './SearchResultLayout.module.scss';
 
 type Props = {
-  children?: ReactNode,
-}
+  children?: ReactNode;
+};
 
 const SearchResultLayout = ({ children }: Props): JSX.Element => {
-
   return (
     <BasicLayout className={`on-search ${commonStyles['on-search']}`}>
-      { children }
+      {children}
     </BasicLayout>
   );
 };

+ 23 - 13
apps/app/src/components/Layout/ShareLinkLayout.tsx

@@ -1,27 +1,37 @@
-import type { ReactNode, JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 import React from 'react';
-
 import dynamic from 'next/dynamic';
 
 import { RawLayout } from './RawLayout';
 
-const PageCreateModal = dynamic(() => import('~/client/components/PageCreateModal'), { ssr: false });
-const GrowiNavbarBottom = dynamic(() => import('~/client/components/Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
-const ShortcutsModal = dynamic(() => import('~/client/components/ShortcutsModal'), { ssr: false });
-const SystemVersion = dynamic(() => import('~/client/components/SystemVersion'), { ssr: false });
-
+const PageCreateModal = dynamic(
+  () => import('~/client/components/PageCreateModal'),
+  { ssr: false },
+);
+const GrowiNavbarBottom = dynamic(
+  () =>
+    import('~/client/components/Navbar/GrowiNavbarBottom').then(
+      (mod) => mod.GrowiNavbarBottom,
+    ),
+  { ssr: false },
+);
+const ShortcutsModal = dynamic(
+  () => import('~/client/components/ShortcutsModal'),
+  { ssr: false },
+);
+const SystemVersion = dynamic(
+  () => import('~/client/components/SystemVersion'),
+  { ssr: false },
+);
 
 type Props = {
-  children?: ReactNode
-}
+  children?: ReactNode;
+};
 
 export const ShareLinkLayout = ({ children }: Props): JSX.Element => {
   return (
     <RawLayout>
-
-      <div className="page-wrapper">
-        {children}
-      </div>
+      <div className="page-wrapper">{children}</div>
 
       <GrowiNavbarBottom />
 

+ 4 - 1
apps/app/src/components/Navbar/GroundGlassBar.tsx

@@ -4,7 +4,10 @@ import styles from './GroundGlassBar.module.scss';
 
 const moduleClass = styles['ground-glass-bar'];
 
-type Props = DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
+type Props = DetailedHTMLProps<
+  React.HTMLAttributes<HTMLDivElement>,
+  HTMLDivElement
+>;
 
 export const GroundGlassBar = (props: Props): JSX.Element => {
   const { className, children, ...rest } = props;

+ 195 - 116
apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert.tsx

@@ -1,42 +1,57 @@
-import React, {
-  useEffect, useState, useCallback, type JSX,
-} from 'react';
-
-import { PageGrant, GroupType } from '@growi/core';
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
+import { GroupType, PageGrant } from '@growi/core';
 import { useTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
-import { UserGroupPageGrantStatus, type IPageGrantData } from '~/interfaces/page';
-import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResGrantData } from '~/interfaces/page-grant';
+import {
+  type IPageGrantData,
+  UserGroupPageGrantStatus,
+} from '~/interfaces/page';
+import type {
+  IRecordApplicableGrant,
+  IResGrantData,
+  PopulatedGrantedGroup,
+} from '~/interfaces/page-grant';
+import {
+  useSWRxApplicableGrant,
+  useSWRxCurrentGrantData,
+  useSWRxCurrentPage,
+} from '~/stores/page';
 import { useCurrentUser } from '~/stores-universal/context';
-import { useSWRxApplicableGrant, useSWRxCurrentGrantData, useSWRxCurrentPage } from '~/stores/page';
 
 type ModalProps = {
-  isOpen: boolean
-  pageId: string
-  dataApplicableGrant: IRecordApplicableGrant
-  currentAndParentPageGrantData: IResGrantData
-  close(): void
-}
+  isOpen: boolean;
+  pageId: string;
+  dataApplicableGrant: IRecordApplicableGrant;
+  currentAndParentPageGrantData: IResGrantData;
+  close(): void;
+};
 
 const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    isOpen, pageId, dataApplicableGrant, currentAndParentPageGrantData, close,
+    isOpen,
+    pageId,
+    dataApplicableGrant,
+    currentAndParentPageGrantData,
+    close,
   } = props;
 
-  const [selectedGrant, setSelectedGrant] = useState<PageGrant>(PageGrant.GRANT_RESTRICTED);
+  const [selectedGrant, setSelectedGrant] = useState<PageGrant>(
+    PageGrant.GRANT_RESTRICTED,
+  );
 
   const [isGroupSelectModalShown, setIsGroupSelectModalShown] = useState(false);
-  const [selectedGroups, setSelectedGroups] = useState<PopulatedGrantedGroup[]>([]);
+  const [selectedGroups, setSelectedGroups] = useState<PopulatedGrantedGroup[]>(
+    [],
+  );
 
   // Alert message state
   const [shouldShowModalAlert, setShowModalAlert] = useState<boolean>(false);
 
-  const applicableGroups = dataApplicableGrant[PageGrant.GRANT_USER_GROUP]?.applicableGroups;
+  const applicableGroups =
+    dataApplicableGrant[PageGrant.GRANT_USER_GROUP]?.applicableGroups;
 
   // Reset state when opened
   useEffect(() => {
@@ -48,17 +63,21 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   }, [isOpen]);
 
   const groupListItemClickHandler = (group: PopulatedGrantedGroup) => {
-    if (selectedGroups.find(g => g.item._id === group.item._id) != null) {
-      setSelectedGroups(selectedGroups.filter(g => g.item._id !== group.item._id));
-    }
-    else {
+    if (selectedGroups.find((g) => g.item._id === group.item._id) != null) {
+      setSelectedGroups(
+        selectedGroups.filter((g) => g.item._id !== group.item._id),
+      );
+    } else {
       setSelectedGroups([...selectedGroups, group]);
     }
   };
 
-  const submit = async() => {
+  const submit = async () => {
     // Validate input values
-    if (selectedGrant === PageGrant.GRANT_USER_GROUP && selectedGroups.length === 0) {
+    if (
+      selectedGrant === PageGrant.GRANT_USER_GROUP &&
+      selectedGroups.length === 0
+    ) {
       setShowModalAlert(true);
       return;
     }
@@ -69,69 +88,89 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
       const apiv3Put = (await import('~/client/util/apiv3-client')).apiv3Put;
       await apiv3Put(`/page/${pageId}/grant`, {
         grant: selectedGrant,
-        userRelatedGrantedGroups: selectedGroups.length !== 0 ? selectedGroups.map((g) => {
-          return { item: g.item._id, type: g.type };
-        }) : null,
+        userRelatedGrantedGroups:
+          selectedGroups.length !== 0
+            ? selectedGroups.map((g) => {
+                return { item: g.item._id, type: g.type };
+              })
+            : null,
       });
 
       const toastSuccess = (await import('~/client/util/toastr')).toastSuccess;
       toastSuccess(t('Successfully updated'));
-    }
-    catch (err) {
+    } catch (err) {
       const toastError = (await import('~/client/util/toastr')).toastError;
       toastError(t('Failed to update'));
     }
   };
 
-  const getGrantLabel = useCallback((isForbidden: boolean, grantData?: IPageGrantData): string => {
-
-    if (isForbidden) {
-      return t('fix_page_grant.modal.grant_label.isForbidden');
-    }
+  const getGrantLabel = useCallback(
+    (isForbidden: boolean, grantData?: IPageGrantData): string => {
+      if (isForbidden) {
+        return t('fix_page_grant.modal.grant_label.isForbidden');
+      }
 
-    if (grantData == null) {
-      return t('fix_page_grant.modal.grant_label.isForbidden');
-    }
+      if (grantData == null) {
+        return t('fix_page_grant.modal.grant_label.isForbidden');
+      }
 
-    if (grantData.grant === 1) {
-      return t('fix_page_grant.modal.grant_label.public');
-    }
+      if (grantData.grant === 1) {
+        return t('fix_page_grant.modal.grant_label.public');
+      }
 
-    if (grantData.grant === 4) {
-      return t('fix_page_grant.modal.radio_btn.only_me');
-    }
+      if (grantData.grant === 4) {
+        return t('fix_page_grant.modal.radio_btn.only_me');
+      }
 
-    if (grantData.grant === 5) {
-      const groupGrantData = grantData.groupGrantData;
-      if (groupGrantData != null) {
-        const userRelatedGrantedGroups = groupGrantData.userRelatedGroups.filter(group => group.status === UserGroupPageGrantStatus.isGranted);
-        if (userRelatedGrantedGroups.length > 0) {
-          const grantedGroupNames = [
-            ...userRelatedGrantedGroups.map(group => group.name),
-            ...groupGrantData.nonUserRelatedGrantedGroups.map(group => group.name),
-          ];
-          return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantedGroupNames.join(', ')})`;
+      if (grantData.grant === 5) {
+        const groupGrantData = grantData.groupGrantData;
+        if (groupGrantData != null) {
+          const userRelatedGrantedGroups =
+            groupGrantData.userRelatedGroups.filter(
+              (group) => group.status === UserGroupPageGrantStatus.isGranted,
+            );
+          if (userRelatedGrantedGroups.length > 0) {
+            const grantedGroupNames = [
+              ...userRelatedGrantedGroups.map((group) => group.name),
+              ...groupGrantData.nonUserRelatedGrantedGroups.map(
+                (group) => group.name,
+              ),
+            ];
+            return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantedGroupNames.join(', ')})`;
+          }
         }
-      }
 
-      return t('fix_page_grant.modal.grant_label.isForbidden');
-    }
+        return t('fix_page_grant.modal.grant_label.isForbidden');
+      }
 
-    throw Error('cannot get grant label'); // this error can't be throwed
-  }, [t]);
+      throw Error('cannot get grant label'); // this error can't be throwed
+    },
+    [t],
+  );
 
   const renderGrantDataLabel = useCallback(() => {
-    const { isForbidden, currentPageGrant, parentPageGrant } = currentAndParentPageGrantData;
+    const { isForbidden, currentPageGrant, parentPageGrant } =
+      currentAndParentPageGrantData;
 
     const currentGrantLabel = getGrantLabel(false, currentPageGrant);
     const parentGrantLabel = getGrantLabel(isForbidden, parentPageGrant);
 
     return (
       <>
-        <p className="mt-3">{ t('fix_page_grant.modal.grant_label.parentPageGrantLabel') + parentGrantLabel }</p>
-        <p>{ t('fix_page_grant.modal.grant_label.currentPageGrantLabel') + currentGrantLabel }</p>
-        {/* eslint-disable-next-line react/no-danger */}
-        <p dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.grant_label.docLink') }} />
+        <p className="mt-3">
+          {t('fix_page_grant.modal.grant_label.parentPageGrantLabel') +
+            parentGrantLabel}
+        </p>
+        <p>
+          {t('fix_page_grant.modal.grant_label.currentPageGrantLabel') +
+            currentGrantLabel}
+        </p>
+        <p
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+          dangerouslySetInnerHTML={{
+            __html: t('fix_page_grant.modal.grant_label.docLink'),
+          }}
+        />
       </>
     );
   }, [t, currentAndParentPageGrantData, getGrantLabel]);
@@ -141,9 +180,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
 
     if (!isGrantAvailable) {
       return (
-        <p className="m-5">
-          { t('fix_page_grant.modal.no_grant_available') }
-        </p>
+        <p className="m-5">{t('fix_page_grant.modal.no_grant_available')}</p>
       );
     }
 
@@ -151,8 +188,13 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
       <>
         <ModalBody>
           <div>
-            {/* eslint-disable-next-line react/no-danger */}
-            <p className="mb-2" dangerouslySetInnerHTML={{ __html: t('fix_page_grant.modal.need_to_fix_grant') }} />
+            <p
+              className="mb-2"
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+              dangerouslySetInnerHTML={{
+                __html: t('fix_page_grant.modal.need_to_fix_grant'),
+              }}
+            />
 
             {/* grant data label */}
             {renderGrantDataLabel()}
@@ -164,12 +206,17 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                   name="grantRestricted"
                   id="grantRestricted"
                   type="radio"
-                  disabled={!(PageGrant.GRANT_RESTRICTED in dataApplicableGrant)}
+                  disabled={
+                    !(PageGrant.GRANT_RESTRICTED in dataApplicableGrant)
+                  }
                   checked={selectedGrant === PageGrant.GRANT_RESTRICTED}
                   onChange={() => setSelectedGrant(PageGrant.GRANT_RESTRICTED)}
                 />
-                <label className="form-label form-check-label" htmlFor="grantRestricted">
-                  { t('fix_page_grant.modal.radio_btn.restrected') }
+                <label
+                  className="form-label form-check-label"
+                  htmlFor="grantRestricted"
+                >
+                  {t('fix_page_grant.modal.radio_btn.restrected')}
                 </label>
               </div>
               <div className="form-check mb-3">
@@ -182,8 +229,11 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                   checked={selectedGrant === PageGrant.GRANT_OWNER}
                   onChange={() => setSelectedGrant(PageGrant.GRANT_OWNER)}
                 />
-                <label className="form-label form-check-label" htmlFor="grantUser">
-                  { t('fix_page_grant.modal.radio_btn.only_me') }
+                <label
+                  className="form-label form-check-label"
+                  htmlFor="grantUser"
+                >
+                  {t('fix_page_grant.modal.radio_btn.only_me')}
                 </label>
               </div>
               <div className="form-check d-flex mb-3">
@@ -192,12 +242,17 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                   name="grantUserGroup"
                   id="grantUserGroup"
                   type="radio"
-                  disabled={!(PageGrant.GRANT_USER_GROUP in dataApplicableGrant)}
+                  disabled={
+                    !(PageGrant.GRANT_USER_GROUP in dataApplicableGrant)
+                  }
                   checked={selectedGrant === PageGrant.GRANT_USER_GROUP}
                   onChange={() => setSelectedGrant(PageGrant.GRANT_USER_GROUP)}
                 />
-                <label className="form-label form-check-label" htmlFor="grantUserGroup">
-                  { t('fix_page_grant.modal.radio_btn.grant_group') }
+                <label
+                  className="form-label form-check-label"
+                  htmlFor="grantUserGroup"
+                >
+                  {t('fix_page_grant.modal.radio_btn.grant_group')}
                 </label>
                 <div className="dropdown ms-2">
                   <button
@@ -207,28 +262,24 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                     onClick={() => setIsGroupSelectModalShown(true)}
                   >
                     <span className="float-start ms-2">
-                      {
-                        selectedGroups.length === 0
-                          ? t('fix_page_grant.modal.select_group_default_text')
-                          : selectedGroups.map(g => g.item.name).join(', ')
-                      }
+                      {selectedGroups.length === 0
+                        ? t('fix_page_grant.modal.select_group_default_text')
+                        : selectedGroups.map((g) => g.item.name).join(', ')}
                     </span>
                   </button>
                 </div>
               </div>
-              {
-                shouldShowModalAlert && (
-                  <p className="alert alert-warning">
-                    {t('fix_page_grant.modal.alert_message')}
-                  </p>
-                )
-              }
+              {shouldShowModalAlert && (
+                <p className="alert alert-warning">
+                  {t('fix_page_grant.modal.alert_message')}
+                </p>
+              )}
             </div>
           </div>
         </ModalBody>
         <ModalFooter>
           <button type="button" className="btn btn-primary" onClick={submit}>
-            { t('fix_page_grant.modal.btn_label') }
+            {t('fix_page_grant.modal.btn_label')}
           </button>
         </ModalFooter>
       </>
@@ -239,7 +290,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     <>
       <Modal size="lg" isOpen={isOpen} toggle={close}>
         <ModalHeader tag="h4" toggle={close}>
-          { t('fix_page_grant.modal.title') }
+          {t('fix_page_grant.modal.title')}
         </ModalHeader>
         {renderModalBodyAndFooter()}
       </Modal>
@@ -248,13 +299,18 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
           isOpen={isGroupSelectModalShown}
           toggle={() => setIsGroupSelectModalShown(false)}
         >
-          <ModalHeader tag="h4" toggle={() => setIsGroupSelectModalShown(false)}>
+          <ModalHeader
+            tag="h4"
+            toggle={() => setIsGroupSelectModalShown(false)}
+          >
             {t('user_group.select_group')}
           </ModalHeader>
           <ModalBody>
             <>
-              { applicableGroups.map((group) => {
-                const groupIsGranted = selectedGroups?.find(g => g.item._id === group.item._id) != null;
+              {applicableGroups.map((group) => {
+                const groupIsGranted =
+                  selectedGroups?.find((g) => g.item._id === group.item._id) !=
+                  null;
                 const activeClass = groupIsGranted ? 'active' : '';
 
                 return (
@@ -264,14 +320,26 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
                     key={group.item._id}
                     onClick={() => groupListItemClickHandler(group)}
                   >
-                    <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
+                    <span className="align-middle">
+                      <input type="checkbox" checked={groupIsGranted} />
+                    </span>
                     <h5 className="d-inline-block ml-3">{group.item.name}</h5>
-                    {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
+                    {group.type === GroupType.externalUserGroup && (
+                      <span className="ml-2 badge badge-pill badge-info">
+                        {group.item.provider}
+                      </span>
+                    )}
                     {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
                   </button>
                 );
-              }) }
-              <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsGroupSelectModalShown(false)}>{t('Done')}</button>
+              })}
+              <button
+                type="button"
+                className="btn btn-primary mt-2 float-right"
+                onClick={() => setIsGroupSelectModalShown(false)}
+              >
+                {t('Done')}
+              </button>
             </>
           </ModalBody>
         </Modal>
@@ -290,8 +358,12 @@ export const FixPageGrantAlert = (): JSX.Element => {
 
   const [isOpen, setOpen] = useState<boolean>(false);
 
-  const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(currentUser != null ? pageId : null);
-  const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
+  const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(
+    currentUser != null ? pageId : null,
+  );
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(
+    currentUser != null ? pageId : null,
+  );
 
   // Dependencies
   if (pageData == null) {
@@ -301,7 +373,10 @@ export const FixPageGrantAlert = (): JSX.Element => {
   if (!hasParent) {
     return <></>;
   }
-  if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
+  if (
+    dataIsGrantNormalized?.isGrantNormalized == null ||
+    dataIsGrantNormalized.isGrantNormalized
+  ) {
     return <></>;
   }
 
@@ -309,27 +384,31 @@ export const FixPageGrantAlert = (): JSX.Element => {
     <>
       <div className="alert alert-warning py-3 ps-4 d-flex flex-column flex-lg-row">
         <div className="flex-grow-1 d-flex align-items-center">
-          <span className="material-symbols-outlined mx-1" aria-hidden="true">error</span>
+          <span className="material-symbols-outlined mx-1" aria-hidden="true">
+            error
+          </span>
           {t('fix_page_grant.alert.description')}
         </div>
         <div className="d-flex align-items-end align-items-lg-center">
-          <button type="button" className="btn btn-info btn-sm rounded-pill px-3" onClick={() => setOpen(true)}>
+          <button
+            type="button"
+            className="btn btn-info btn-sm rounded-pill px-3"
+            onClick={() => setOpen(true)}
+          >
             {t('fix_page_grant.alert.btn_label')}
           </button>
         </div>
       </div>
 
-      {
-        pageId != null && dataApplicableGrant != null && (
-          <FixPageGrantModal
-            isOpen={isOpen}
-            pageId={pageId}
-            dataApplicableGrant={dataApplicableGrant}
-            currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
-            close={() => setOpen(false)}
-          />
-        )
-      }
+      {pageId != null && dataApplicableGrant != null && (
+        <FixPageGrantModal
+          isOpen={isOpen}
+          pageId={pageId}
+          dataApplicableGrant={dataApplicableGrant}
+          currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
+          close={() => setOpen(false)}
+        />
+      )}
     </>
   );
 };

+ 13 - 7
apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert.tsx

@@ -1,28 +1,34 @@
 import type { JSX } from 'react';
-
 import { useTranslation } from 'react-i18next';
 
-import { useElasticsearchMaxBodyLengthToIndex } from '~/stores-universal/context';
 import { useSWRxCurrentPage } from '~/stores/page';
-
+import { useElasticsearchMaxBodyLengthToIndex } from '~/stores-universal/context';
 
 export const FullTextSearchNotCoverAlert = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data: elasticsearchMaxBodyLengthToIndex } = useElasticsearchMaxBodyLengthToIndex();
+  const { data: elasticsearchMaxBodyLengthToIndex } =
+    useElasticsearchMaxBodyLengthToIndex();
   const { data } = useSWRxCurrentPage();
 
   const markdownLength = data?.revision?.body?.length;
 
-  if (markdownLength == null || elasticsearchMaxBodyLengthToIndex == null || markdownLength <= elasticsearchMaxBodyLengthToIndex) {
+  if (
+    markdownLength == null ||
+    elasticsearchMaxBodyLengthToIndex == null ||
+    markdownLength <= elasticsearchMaxBodyLengthToIndex
+  ) {
     return <></>;
   }
 
   return (
     <div className="alert alert-warning">
-      <strong>{t('Warning')}: {t('page_page.notice.not_indexed1')}</strong><br />
+      <strong>
+        {t('Warning')}: {t('page_page.notice.not_indexed1')}
+      </strong>
+      <br />
       <small
-        // eslint-disable-next-line react/no-danger
+        // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
         dangerouslySetInnerHTML={{
           __html: t('page_page.notice.not_indexed2', {
             threshold: `<code>ELASTICSEARCH_MAX_BODY_LENGTH_TO_INDEX=${elasticsearchMaxBodyLengthToIndex}</code>`,

+ 17 - 7
apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx

@@ -1,10 +1,13 @@
-import React, { useCallback, type JSX } from 'react';
-
-import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
+import React, { type JSX, useCallback } from 'react';
 import { useRouter } from 'next/router';
+import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'react-i18next';
 
-import { useSWRxCurrentPage, useSWRMUTxCurrentPage, useIsLatestRevision } from '~/stores/page';
+import {
+  useIsLatestRevision,
+  useSWRMUTxCurrentPage,
+  useSWRxCurrentPage,
+} from '~/stores/page';
 
 export const OldRevisionAlert = (): JSX.Element => {
   const router = useRouter();
@@ -14,7 +17,7 @@ export const OldRevisionAlert = (): JSX.Element => {
   const { data: page } = useSWRxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
-  const onClickShowLatestButton = useCallback(async() => {
+  const onClickShowLatestButton = useCallback(async () => {
     if (page == null) {
       return;
     }
@@ -31,8 +34,15 @@ export const OldRevisionAlert = (): JSX.Element => {
   return (
     <div className="alert alert-warning">
       <strong>{t('Warning')}: </strong> {t('page_page.notice.version')}
-      <button type="button" className="btn btn-outline-natural-secondary" onClick={onClickShowLatestButton}>
-        <span className="material-symbols-outlined me-1">arrow_circle_right</span>{t('Show latest')}
+      <button
+        type="button"
+        className="btn btn-outline-natural-secondary"
+        onClick={onClickShowLatestButton}
+      >
+        <span className="material-symbols-outlined me-1">
+          arrow_circle_right
+        </span>
+        {t('Show latest')}
       </button>
     </div>
   );

+ 20 - 8
apps/app/src/components/PageView/PageAlerts/PageAlerts.tsx

@@ -1,5 +1,4 @@
 import type { JSX } from 'react';
-
 import dynamic from 'next/dynamic';
 
 import { useIsNotFound } from '~/stores/page';
@@ -9,21 +8,34 @@ import { PageGrantAlert } from './PageGrantAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 import { WipPageAlert } from './WipPageAlert';
 
-
-const FullTextSearchNotCoverAlert = dynamic(() => import('./FullTextSearchNotCoverAlert').then(mod => mod.FullTextSearchNotCoverAlert), { ssr: false });
-const PageRedirectedAlert = dynamic(() => import('./PageRedirectedAlert').then(mod => mod.PageRedirectedAlert), { ssr: false });
-const FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { ssr: false });
-const TrashPageAlert = dynamic(() => import('./TrashPageAlert').then(mod => mod.TrashPageAlert), { ssr: false });
+const FullTextSearchNotCoverAlert = dynamic(
+  () =>
+    import('./FullTextSearchNotCoverAlert').then(
+      (mod) => mod.FullTextSearchNotCoverAlert,
+    ),
+  { ssr: false },
+);
+const PageRedirectedAlert = dynamic(
+  () => import('./PageRedirectedAlert').then((mod) => mod.PageRedirectedAlert),
+  { ssr: false },
+);
+const FixPageGrantAlert = dynamic(
+  () => import('./FixPageGrantAlert').then((mod) => mod.FixPageGrantAlert),
+  { ssr: false },
+);
+const TrashPageAlert = dynamic(
+  () => import('./TrashPageAlert').then((mod) => mod.TrashPageAlert),
+  { ssr: false },
+);
 
 export const PageAlerts = (): JSX.Element => {
-
   const { data: isNotFound } = useIsNotFound();
 
   return (
     <div className="row d-edit-none">
       <div className="col-sm-12">
         {/* alerts */}
-        { !isNotFound && <FixPageGrantAlert /> }
+        {!isNotFound && <FixPageGrantAlert />}
         <FullTextSearchNotCoverAlert />
         <WipPageAlert />
         <PageGrantAlert />

+ 9 - 9
apps/app/src/components/PageView/PageAlerts/PageGrantAlert.tsx

@@ -1,11 +1,9 @@
 import React, { type JSX } from 'react';
-
 import { isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
 import { useSWRxCurrentPage } from '~/stores/page';
 
-
 export const PageGrantAlert = (): JSX.Element => {
   const { t } = useTranslation();
   const { data: pageData } = useSWRxCurrentPage();
@@ -15,7 +13,7 @@ export const PageGrantAlert = (): JSX.Element => {
   }
 
   const populatedGrantedGroups = () => {
-    return pageData.grantedGroups.filter(group => isPopulated(group.item));
+    return pageData.grantedGroups.filter((group) => isPopulated(group.item));
   };
 
   const renderAlertContent = () => {
@@ -23,14 +21,16 @@ export const PageGrantAlert = (): JSX.Element => {
       if (pageData.grant === 2) {
         return (
           <>
-            <span className="material-symbols-outlined me-1">link</span><strong>{t('Anyone with the link')}</strong>
+            <span className="material-symbols-outlined me-1">link</span>
+            <strong>{t('Anyone with the link')}</strong>
           </>
         );
       }
       if (pageData.grant === 4) {
         return (
           <>
-            <span className="material-symbols-outlined me-1">lock</span><strong>{t('Only me')}</strong>
+            <span className="material-symbols-outlined me-1">lock</span>
+            <strong>{t('Only me')}</strong>
           </>
         );
       }
@@ -38,9 +38,10 @@ export const PageGrantAlert = (): JSX.Element => {
         return (
           <>
             <span className="material-symbols-outlined me-1">account_tree</span>
-            <strong>{
-              populatedGrantedGroups().map(g => g.item.name).join(', ')
-            }
+            <strong>
+              {populatedGrantedGroups()
+                .map((g) => g.item.name)
+                .join(', ')}
             </strong>
           </>
         );
@@ -53,7 +54,6 @@ export const PageGrantAlert = (): JSX.Element => {
     );
   };
 
-
   return (
     <p data-testid="page-grant-alert" className="alert alert-primary py-3 px-4">
       {renderAlertContent()}

+ 15 - 9
apps/app/src/components/PageView/PageAlerts/PageRedirectedAlert.tsx

@@ -1,5 +1,4 @@
-import React, { useState, useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { useCurrentPagePath } from '~/stores/page';
@@ -12,7 +11,7 @@ export const PageRedirectedAlert = React.memo((): JSX.Element => {
 
   const [isUnlinked, setIsUnlinked] = useState(false);
 
-  const unlinkButtonClickHandler = useCallback(async() => {
+  const unlinkButtonClickHandler = useCallback(async () => {
     if (currentPagePath == null) {
       return;
     }
@@ -20,8 +19,7 @@ export const PageRedirectedAlert = React.memo((): JSX.Element => {
       const unlink = (await import('~/client/services/page-operation')).unlink;
       await unlink(currentPagePath);
       setIsUnlinked(true);
-    }
-    catch (err) {
+    } catch (err) {
       const toastError = (await import('~/client/util/toastr')).toastError;
       toastError(err);
     }
@@ -34,7 +32,7 @@ export const PageRedirectedAlert = React.memo((): JSX.Element => {
   if (isUnlinked) {
     return (
       <div className="alert alert-info d-edit-none py-3 px-4">
-        <strong>{ t('Unlinked') }: </strong> { t('page_page.notice.unlinked') }
+        <strong>{t('Unlinked')}: </strong> {t('page_page.notice.unlinked')}
       </div>
     );
   }
@@ -42,10 +40,18 @@ export const PageRedirectedAlert = React.memo((): JSX.Element => {
   return (
     <div className="alert alert-pink d-edit-none py-3 px-4 d-flex align-items-center justify-content-between">
       <span>
-        <strong>{ t('Redirected') }:</strong> { t('page_page.notice.redirected')} <code>{redirectFrom}</code> {t('page_page.notice.redirected_period')}
+        <strong>{t('Redirected')}:</strong> {t('page_page.notice.redirected')}{' '}
+        <code>{redirectFrom}</code> {t('page_page.notice.redirected_period')}
       </span>
-      <button type="button" id="unlink-page-button" onClick={unlinkButtonClickHandler} className="btn btn-outline-dark btn-sm float-end">
-        <span className="material-symbols-outlined" aria-hidden="true">link_off</span>
+      <button
+        type="button"
+        id="unlink-page-button"
+        onClick={unlinkButtonClickHandler}
+        className="btn btn-outline-dark btn-sm float-end"
+      >
+        <span className="material-symbols-outlined" aria-hidden="true">
+          link_off
+        </span>
         {t('unlink_redirection')}
       </button>
     </div>

+ 10 - 9
apps/app/src/components/PageView/PageAlerts/PageStaleAlert.tsx

@@ -1,22 +1,23 @@
 import type { JSX } from 'react';
-
 import { isIPageInfoForEntity } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
-
-import { useIsEnabledStaleNotification } from '~/stores-universal/context';
 import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+import { useIsEnabledStaleNotification } from '~/stores-universal/context';
 
-
-export const PageStaleAlert = ():JSX.Element => {
+export const PageStaleAlert = (): JSX.Element => {
   const { t } = useTranslation();
   const { data: isEnabledStaleNotification } = useIsEnabledStaleNotification();
 
   // Todo: determine if it should fetch or not like useSWRxPageInfo below after https://redmine.weseek.co.jp/issues/96788
   const { data: pageData } = useSWRxCurrentPage();
-  const { data: pageInfo } = useSWRxPageInfo(isEnabledStaleNotification ? pageData?._id : null);
+  const { data: pageInfo } = useSWRxPageInfo(
+    isEnabledStaleNotification ? pageData?._id : null,
+  );
 
-  const contentAge = isIPageInfoForEntity(pageInfo) ? pageInfo.contentAge : null;
+  const contentAge = isIPageInfoForEntity(pageInfo)
+    ? pageInfo.contentAge
+    : null;
 
   if (!isEnabledStaleNotification) {
     return <></>;
@@ -26,7 +27,7 @@ export const PageStaleAlert = ():JSX.Element => {
     return <></>;
   }
 
-  let alertClass;
+  let alertClass: string;
   switch (contentAge) {
     case 1:
       alertClass = 'alert-info';
@@ -41,7 +42,7 @@ export const PageStaleAlert = ():JSX.Element => {
   return (
     <div className={`alert ${alertClass}`}>
       <span className="material-symbols-outlined me-1">hourglass</span>
-      <strong>{ t('page_page.notice.stale', { count: contentAge }) }</strong>
+      <strong>{t('page_page.notice.stale', { count: contentAge })}</strong>
     </div>
   );
 };

+ 58 - 20
apps/app/src/components/PageView/PageAlerts/TrashPageAlert.tsx

@@ -1,18 +1,20 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
+import { useRouter } from 'next/router';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
-import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import {
-  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage, useSWRMUTxCurrentPage,
+  useCurrentPagePath,
+  useIsTrashPage,
+  useSWRMUTxCurrentPage,
+  useSWRxCurrentPage,
+  useSWRxPageInfo,
 } from '~/stores/page';
 import { mutateRecentlyUpdated } from '~/stores/page-listing';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
-
 const onDeletedHandler = (pathOrPathsToDelete) => {
   if (typeof pathOrPathsToDelete !== 'string') {
     return;
@@ -25,7 +27,8 @@ export const TrashPageAlert = (): JSX.Element => {
   const { t } = useTranslation();
   const router = useRouter();
 
-  const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
+  const { data: isAbleToShowTrashPageManagementButtons } =
+    useIsAbleToShowTrashPageManagementButtons();
   const { data: pageData } = useSWRxCurrentPage();
   const { data: isTrashPage } = useIsTrashPage();
   const pageId = pageData?._id;
@@ -39,7 +42,9 @@ export const TrashPageAlert = (): JSX.Element => {
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
   const deleteUser = pageData?.deleteUser;
-  const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
+  const deletedAt = pageData?.deletedAt
+    ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm')
+    : '';
   const revisionId = pageData?.revision?._id;
   const isEmptyPage = pageId == null || revisionId == null || pagePath == null;
 
@@ -48,25 +53,36 @@ export const TrashPageAlert = (): JSX.Element => {
     if (isEmptyPage) {
       return;
     }
-    const putBackedHandler = async() => {
+    const putBackedHandler = async () => {
       if (currentPagePath == null) {
         return;
       }
       try {
-        const unlink = (await import('~/client/services/page-operation')).unlink;
+        const unlink = (await import('~/client/services/page-operation'))
+          .unlink;
         unlink(currentPagePath);
 
         router.push(`/${pageId}`);
         mutateCurrentPage();
         mutateRecentlyUpdated();
-      }
-      catch (err) {
+      } catch (err) {
         const toastError = (await import('~/client/util/toastr')).toastError;
         toastError(err);
       }
     };
-    openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [currentPagePath, mutateCurrentPage, openPutBackPageModal, pageId, pagePath, router, isEmptyPage]);
+    openPutBackPageModal(
+      { pageId, path: pagePath },
+      { onPutBacked: putBackedHandler },
+    );
+  }, [
+    currentPagePath,
+    mutateCurrentPage,
+    openPutBackPageModal,
+    pageId,
+    pagePath,
+    router,
+    isEmptyPage,
+  ]);
 
   const openPageDeleteModalHandler = useCallback(() => {
     // User cannot operate empty page.
@@ -93,7 +109,10 @@ export const TrashPageAlert = (): JSX.Element => {
           onClick={openPutbackPageModalHandler}
           data-testid="put-back-button"
         >
-          <span className="material-symbols-outlined" aria-hidden="true">undo</span> {t('Put Back')}
+          <span className="material-symbols-outlined" aria-hidden="true">
+            undo
+          </span>{' '}
+          {t('Put Back')}
         </button>
         <button
           type="button"
@@ -101,11 +120,19 @@ export const TrashPageAlert = (): JSX.Element => {
           disabled={!(pageInfo?.isAbleToDeleteCompletely ?? false)}
           onClick={openPageDeleteModalHandler}
         >
-          <span className="material-symbols-outlined" aria-hidden="true">delete_forever</span> {t('Delete Completely')}
+          <span className="material-symbols-outlined" aria-hidden="true">
+            delete_forever
+          </span>{' '}
+          {t('Delete Completely')}
         </button>
       </>
     );
-  }, [openPageDeleteModalHandler, openPutbackPageModalHandler, pageInfo?.isAbleToDeleteCompletely, t]);
+  }, [
+    openPageDeleteModalHandler,
+    openPutbackPageModalHandler,
+    pageInfo?.isAbleToDeleteCompletely,
+    t,
+  ]);
 
   // Show this alert only for non-empty pages in trash.
   if (!isTrashPage || isEmptyPage) {
@@ -114,17 +141,28 @@ export const TrashPageAlert = (): JSX.Element => {
 
   return (
     <>
-      <div className="alert alert-warning py-3 ps-4 d-flex flex-column flex-lg-row" data-testid="trash-page-alert">
+      <div
+        className="alert alert-warning py-3 ps-4 d-flex flex-column flex-lg-row"
+        data-testid="trash-page-alert"
+      >
         <div className="flex-grow-1">
-          This page is in the trash <span className="material-symbols-outlined" aria-hidden="true">delete</span>.
+          This page is in the trash{' '}
+          <span className="material-symbols-outlined" aria-hidden="true">
+            delete
+          </span>
+          .
           <br />
           <UserPicture user={deleteUser} />
           <span className="ms-2">
-            Deleted by {deleteUser?.name} at <span data-vrt-blackout-datetime>{deletedAt ?? pageData?.updatedAt}</span>
+            Deleted by {deleteUser?.name} at{' '}
+            <span data-vrt-blackout-datetime>
+              {deletedAt ?? pageData?.updatedAt}
+            </span>
           </span>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">
-          {isAbleToShowTrashPageManagementButtons && renderTrashPageManagementButtons()}
+          {isAbleToShowTrashPageManagementButtons &&
+            renderTrashPageManagementButtons()}
         </div>
       </div>
     </>

+ 10 - 11
apps/app/src/components/PageView/PageAlerts/WipPageAlert.tsx

@@ -1,16 +1,14 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
 
-
 export const WipPageAlert = (): JSX.Element => {
   const { t } = useTranslation();
   const { data: currentPage } = useSWRxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
-  const clickPagePublishButton = useCallback(async() => {
+  const clickPagePublishButton = useCallback(async () => {
     const pageId = currentPage?._id;
 
     if (pageId == null) {
@@ -18,27 +16,28 @@ export const WipPageAlert = (): JSX.Element => {
     }
 
     try {
-      const publish = (await import('~/client/services/page-operation')).publish;
+      const publish = (await import('~/client/services/page-operation'))
+        .publish;
       await publish(pageId);
 
       await mutateCurrentPage();
 
-      const mutatePageTree = (await import('~/stores/page-listing')).mutatePageTree;
+      const mutatePageTree = (await import('~/stores/page-listing'))
+        .mutatePageTree;
       await mutatePageTree();
 
-      const mutateRecentlyUpdated = (await import('~/stores/page-listing')).mutateRecentlyUpdated;
+      const mutateRecentlyUpdated = (await import('~/stores/page-listing'))
+        .mutateRecentlyUpdated;
       await mutateRecentlyUpdated();
 
       const toastSuccess = (await import('~/client/util/toastr')).toastSuccess;
       toastSuccess(t('wip_page.success_publish_page'));
-    }
-    catch {
+    } catch {
       const toastError = (await import('~/client/util/toastr')).toastError;
       toastError(t('wip_page.fail_publish_page'));
     }
   }, [currentPage?._id, mutateCurrentPage, t]);
 
-
   if (!currentPage?.wip) {
     return <></>;
   }
@@ -52,7 +51,7 @@ export const WipPageAlert = (): JSX.Element => {
         className="btn btn-outline-secondary ms-auto"
         onClick={clickPagePublishButton}
       >
-        {t('wip_page.publish_page') }
+        {t('wip_page.publish_page')}
       </button>
     </p>
   );

+ 26 - 14
apps/app/src/components/PageView/PageContentFooter.tsx

@@ -1,34 +1,46 @@
 import type { JSX } from 'react';
-
-import type { IPage, IPagePopulatedToShowRevision } from '@growi/core';
 import dynamic from 'next/dynamic';
+import type { IPage, IPagePopulatedToShowRevision } from '@growi/core';
 
 import styles from './PageContentFooter.module.scss';
 
-
-const AuthorInfo = dynamic(() => import('~/client/components/AuthorInfo').then(mod => mod.AuthorInfo), { ssr: false });
+const AuthorInfo = dynamic(
+  () => import('~/client/components/AuthorInfo').then((mod) => mod.AuthorInfo),
+  { ssr: false },
+);
 
 export type PageContentFooterProps = {
-  page: IPage | IPagePopulatedToShowRevision,
-}
-
-export const PageContentFooter = (props: PageContentFooterProps): JSX.Element => {
+  page: IPage | IPagePopulatedToShowRevision;
+};
 
+export const PageContentFooter = (
+  props: PageContentFooterProps,
+): JSX.Element => {
   const { page } = props;
 
-  const {
-    creator, lastUpdateUser, createdAt, updatedAt,
-  } = page;
+  const { creator, lastUpdateUser, createdAt, updatedAt } = page;
 
   if (page.isEmpty) {
     return <></>;
   }
 
   return (
-    <div className={`${styles['page-content-footer']} my-4 pt-4 d-edit-none d-print-none}`}>
+    <div
+      className={`${styles['page-content-footer']} my-4 pt-4 d-edit-none d-print-none}`}
+    >
       <div className="page-meta">
-        <AuthorInfo user={creator} date={createdAt} mode="create" locate="footer" />
-        <AuthorInfo user={lastUpdateUser} date={updatedAt} mode="update" locate="footer" />
+        <AuthorInfo
+          user={creator}
+          date={createdAt}
+          mode="create"
+          locate="footer"
+        />
+        <AuthorInfo
+          user={lastUpdateUser}
+          date={updatedAt}
+          mode="update"
+          locate="footer"
+        />
       </div>
     </div>
   );

+ 121 - 56
apps/app/src/components/PageView/PageView.tsx

@@ -1,56 +1,99 @@
 import React, {
-  useEffect, useMemo, useRef, useState, type JSX,
+  type JSX,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
 } from 'react';
-
+import dynamic from 'next/dynamic';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
-import dynamic from 'next/dynamic';
 
 import { PagePathNavTitle } from '~/components/Common/PagePathNavTitle';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import { generateSSRViewOptions } from '~/services/renderer/renderer';
+import { useIsNotFound, useSWRxCurrentPage } from '~/stores/page';
+import { useViewOptions } from '~/stores/renderer';
 import {
-  useIsForbidden, useIsIdenticalPath, useIsNotCreatable,
+  useIsForbidden,
+  useIsIdenticalPath,
+  useIsNotCreatable,
 } from '~/stores-universal/context';
-import { useSWRxCurrentPage, useIsNotFound } from '~/stores/page';
-import { useViewOptions } from '~/stores/renderer';
 
 import { UserInfo } from '../User/UserInfo';
-
 import { PageAlerts } from './PageAlerts/PageAlerts';
 import { PageContentFooter } from './PageContentFooter';
 import { PageViewLayout } from './PageViewLayout';
 import RevisionRenderer from './RevisionRenderer';
 
-
-const NotCreatablePage = dynamic(() => import('~/client/components/NotCreatablePage').then(mod => mod.NotCreatablePage), { ssr: false });
-const ForbiddenPage = dynamic(() => import('~/client/components/ForbiddenPage'), { ssr: false });
-const NotFoundPage = dynamic(() => import('~/client/components/NotFoundPage'), { ssr: false });
-const PageSideContents = dynamic(() => import('~/client/components/PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
-const PageContentsUtilities = dynamic(() => import('~/client/components/Page/PageContentsUtilities').then(mod => mod.PageContentsUtilities), { ssr: false });
-const Comments = dynamic(() => import('~/client/components/Comments').then(mod => mod.Comments), { ssr: false });
-const UsersHomepageFooter = dynamic(() => import('~/client/components/UsersHomepageFooter')
-  .then(mod => mod.UsersHomepageFooter), { ssr: false });
-const IdenticalPathPage = dynamic(() => import('~/client/components/IdenticalPathPage').then(mod => mod.IdenticalPathPage), { ssr: false });
-const SlideRenderer = dynamic(() => import('~/client/components/Page/SlideRenderer').then(mod => mod.SlideRenderer), { ssr: false });
-
+const NotCreatablePage = dynamic(
+  () =>
+    import('~/client/components/NotCreatablePage').then(
+      (mod) => mod.NotCreatablePage,
+    ),
+  { ssr: false },
+);
+const ForbiddenPage = dynamic(
+  () => import('~/client/components/ForbiddenPage'),
+  { ssr: false },
+);
+const NotFoundPage = dynamic(() => import('~/client/components/NotFoundPage'), {
+  ssr: false,
+});
+const PageSideContents = dynamic(
+  () =>
+    import('~/client/components/PageSideContents').then(
+      (mod) => mod.PageSideContents,
+    ),
+  { ssr: false },
+);
+const PageContentsUtilities = dynamic(
+  () =>
+    import('~/client/components/Page/PageContentsUtilities').then(
+      (mod) => mod.PageContentsUtilities,
+    ),
+  { ssr: false },
+);
+const Comments = dynamic(
+  () => import('~/client/components/Comments').then((mod) => mod.Comments),
+  { ssr: false },
+);
+const UsersHomepageFooter = dynamic(
+  () =>
+    import('~/client/components/UsersHomepageFooter').then(
+      (mod) => mod.UsersHomepageFooter,
+    ),
+  { ssr: false },
+);
+const IdenticalPathPage = dynamic(
+  () =>
+    import('~/client/components/IdenticalPathPage').then(
+      (mod) => mod.IdenticalPathPage,
+    ),
+  { ssr: false },
+);
+const SlideRenderer = dynamic(
+  () =>
+    import('~/client/components/Page/SlideRenderer').then(
+      (mod) => mod.SlideRenderer,
+    ),
+  { ssr: false },
+);
 
 type Props = {
-  pagePath: string,
-  rendererConfig: RendererConfig,
-  initialPage?: IPagePopulatedToShowRevision,
-  className?: string,
-}
+  pagePath: string;
+  rendererConfig: RendererConfig;
+  initialPage?: IPagePopulatedToShowRevision;
+  className?: string;
+};
 
 export const PageView = (props: Props): JSX.Element => {
-
   const commentsContainerRef = useRef<HTMLDivElement>(null);
 
-  const {
-    pagePath, initialPage, rendererConfig, className,
-  } = props;
+  const { pagePath, initialPage, rendererConfig, className } = props;
 
   const { data: isIdenticalPathPage } = useIsIdenticalPath();
   const { data: isForbidden } = useIsForbidden();
@@ -66,11 +109,15 @@ export const PageView = (props: Props): JSX.Element => {
 
   const shouldExpandContent = useShouldExpandContent(page);
 
-
   const markdown = page?.revision?.body;
-  const isSlide = useSlidesByFrontmatter(markdown, rendererConfig.isEnabledMarp);
+  const isSlide = useSlidesByFrontmatter(
+    markdown,
+    rendererConfig.isEnabledMarp,
+  );
 
-  const [currentPageId, setCurrentPageId] = useState<string | undefined>(page?._id);
+  const [currentPageId, setCurrentPageId] = useState<string | undefined>(
+    page?._id,
+  );
 
   useEffect(() => {
     if (page?._id !== undefined) {
@@ -90,7 +137,9 @@ export const PageView = (props: Props): JSX.Element => {
       return;
     }
 
-    const contentContainer = document.getElementById('page-view-content-container');
+    const contentContainer = document.getElementById(
+      'page-view-content-container',
+    );
     if (contentContainer == null) return;
 
     const targetId = decodeURIComponent(hash.slice(1));
@@ -127,45 +176,51 @@ export const PageView = (props: Props): JSX.Element => {
     }
   }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
 
-  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />;
+  const headerContents = (
+    <PagePathNavTitle
+      pageId={page?._id}
+      pagePath={pagePath}
+      isWipPage={page?.wip}
+    />
+  );
 
-  const sideContents = !isNotFound && !isNotCreatable
-    ? (
-      <PageSideContents page={page} />
-    )
-    : null;
+  const sideContents =
+    !isNotFound && !isNotCreatable ? <PageSideContents page={page} /> : null;
 
-  const footerContents = !isIdenticalPathPage && !isNotFound
-    ? (
+  const footerContents =
+    !isIdenticalPathPage && !isNotFound ? (
       <>
-        {(isUsersHomepagePath && page.creator != null) && (
+        {isUsersHomepagePath && page.creator != null && (
           <UsersHomepageFooter creatorId={page.creator._id} />
         )}
         <PageContentFooter page={page} />
       </>
-    )
-    : null;
+    ) : null;
 
-  const Contents = () => {
+  const Contents = useCallback(() => {
     if (isNotFound || page?.revision == null) {
       return <NotFoundPage path={pagePath} />;
     }
 
     const markdown = page.revision.body;
-    const rendererOptions = viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
+    const rendererOptions =
+      viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
 
     return (
       <>
         <PageContentsUtilities />
 
         <div className="flex-expand-vert justify-content-between">
-
-          { isSlide != null
-            ? <SlideRenderer marp={isSlide.marp} markdown={markdown} />
-            : <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
-          }
-
-          { !isIdenticalPathPage && !isNotFound && (
+          {isSlide != null ? (
+            <SlideRenderer marp={isSlide.marp} markdown={markdown} />
+          ) : (
+            <RevisionRenderer
+              rendererOptions={rendererOptions}
+              markdown={markdown}
+            />
+          )}
+
+          {!isIdenticalPathPage && !isNotFound && (
             <div id="comments-container" ref={commentsContainerRef}>
               <Comments
                 pageId={page._id}
@@ -173,11 +228,20 @@ export const PageView = (props: Props): JSX.Element => {
                 revision={page.revision}
               />
             </div>
-          ) }
+          )}
         </div>
       </>
     );
-  };
+  }, [
+    isNotFound,
+    page?.revision,
+    page?._id,
+    rendererConfig,
+    pagePath,
+    viewOptions,
+    isSlide,
+    isIdenticalPathPage,
+  ]);
 
   return (
     <PageViewLayout
@@ -192,13 +256,14 @@ export const PageView = (props: Props): JSX.Element => {
       {specialContents}
       {specialContents == null && (
         <>
-          {(isUsersHomepagePath && page?.creator != null) && <UserInfo author={page.creator} />}
+          {isUsersHomepagePath && page?.creator != null && (
+            <UserInfo author={page.creator} />
+          )}
           <div id="page-view-content-container" className="flex-expand-vert">
             <Contents />
           </div>
         </>
       )}
-
     </PageViewLayout>
   );
 };

+ 36 - 31
apps/app/src/components/PageView/PageViewLayout.tsx

@@ -1,4 +1,4 @@
-import type { ReactNode, JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 
 import styles from './PageViewLayout.module.scss';
 
@@ -6,18 +6,21 @@ const pageViewLayoutClass = styles['page-view-layout'] ?? '';
 const _fluidLayoutClass = styles['fluid-layout'] ?? '';
 
 type Props = {
-  className?: string,
-  children?: ReactNode,
-  headerContents?: ReactNode,
-  sideContents?: ReactNode,
-  footerContents?: ReactNode,
-  expandContentWidth?: boolean,
-}
+  className?: string;
+  children?: ReactNode;
+  headerContents?: ReactNode;
+  sideContents?: ReactNode;
+  footerContents?: ReactNode;
+  expandContentWidth?: boolean;
+};
 
 export const PageViewLayout = (props: Props): JSX.Element => {
   const {
     className,
-    children, headerContents, sideContents, footerContents,
+    children,
+    headerContents,
+    sideContents,
+    footerContents,
     expandContentWidth,
   } = props;
 
@@ -25,36 +28,38 @@ export const PageViewLayout = (props: Props): JSX.Element => {
 
   return (
     <>
-      <div className={`main ${className} ${pageViewLayoutClass} ${fluidLayoutClass} flex-expand-vert ps-sidebar`}>
+      <div
+        className={`main ${className} ${pageViewLayoutClass} ${fluidLayoutClass} flex-expand-vert ps-sidebar`}
+      >
         <div className="container-lg wide-gutter-x-lg grw-container-convertible flex-expand-vert">
-          { headerContents != null && headerContents }
-          { sideContents != null
-            ? (
-              <div className="flex-expand-horiz gap-3 z-0">
-                <div className="flex-expand-vert flex-basis-0 mw-0">
-                  {children}
-                </div>
-                <div className="grw-side-contents-container col-lg-3  d-edit-none d-print-none" data-vrt-blackout-side-contents>
-                  <div className="grw-side-contents-sticky-container">
-                    {sideContents}
-                  </div>
-                </div>
-              </div>
-            )
-            : (
-              <div className="z-0">
+          {headerContents != null && headerContents}
+          {sideContents != null ? (
+            <div className="flex-expand-horiz gap-3 z-0">
+              <div className="flex-expand-vert flex-basis-0 mw-0">
                 {children}
               </div>
-            )
-          }
+              <div
+                className="grw-side-contents-container col-lg-3  d-edit-none d-print-none"
+                data-vrt-blackout-side-contents
+              >
+                <div className="grw-side-contents-sticky-container">
+                  {sideContents}
+                </div>
+              </div>
+            </div>
+          ) : (
+            <div className="z-0">{children}</div>
+          )}
         </div>
       </div>
 
-      { footerContents != null && (
-        <footer className={`footer d-edit-none container-lg wide-gutter-x-lg ${fluidLayoutClass}`}>
+      {footerContents != null && (
+        <footer
+          className={`footer d-edit-none container-lg wide-gutter-x-lg ${fluidLayoutClass}`}
+        >
           {footerContents}
         </footer>
-      ) }
+      )}
     </>
   );
 };

+ 23 - 21
apps/app/src/components/PageView/RevisionRenderer.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
-
 import type { FallbackProps } from 'react-error-boundary';
 import { ErrorBoundary } from 'react-error-boundary';
 import ReactMarkdown from 'react-markdown';
@@ -9,31 +8,35 @@ import loggerFactory from '~/utils/logger';
 
 import 'katex/dist/katex.min.css';
 
-
 const logger = loggerFactory('components:Page:RevisionRenderer');
 
 type Props = {
-  rendererOptions: RendererOptions,
-  markdown: string,
-  additionalClassName?: string,
-}
-
-const ErrorFallback: React.FC<FallbackProps> = React.memo(({ error, resetErrorBoundary }) => {
-  return (
-    <div role="alert">
-      <p>Something went wrong:</p>
-      <pre>{error.message}</pre>
-      <button type="button" className="btn btn-primary" onClick={resetErrorBoundary}>Reload</button>
-    </div>
-  );
-});
+  rendererOptions: RendererOptions;
+  markdown: string;
+  additionalClassName?: string;
+};
+
+const ErrorFallback: React.FC<FallbackProps> = React.memo(
+  ({ error, resetErrorBoundary }) => {
+    return (
+      <div role="alert">
+        <p>Something went wrong:</p>
+        <pre>{error.message}</pre>
+        <button
+          type="button"
+          className="btn btn-primary"
+          onClick={resetErrorBoundary}
+        >
+          Reload
+        </button>
+      </div>
+    );
+  },
+);
 ErrorFallback.displayName = 'ErrorFallback';
 
 const RevisionRenderer = React.memo((props: Props): JSX.Element => {
-
-  const {
-    rendererOptions, markdown, additionalClassName,
-  } = props;
+  const { rendererOptions, markdown, additionalClassName } = props;
 
   return (
     <ErrorBoundary FallbackComponent={ErrorFallback}>
@@ -45,7 +48,6 @@ const RevisionRenderer = React.memo((props: Props): JSX.Element => {
       </ReactMarkdown>
     </ErrorBoundary>
   );
-
 });
 RevisionRenderer.displayName = 'RevisionRenderer';
 

+ 45 - 26
apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx

@@ -1,5 +1,4 @@
-import type { ReactNode, JSX } from 'react';
-
+import type { JSX, ReactNode } from 'react';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 
@@ -12,20 +11,21 @@ Object.entries<object>(oneDark).forEach(([key, value]) => {
   }
 });
 
-
 type InlineCodeBlockProps = {
-  children: ReactNode,
-  className?: string,
-}
+  children: ReactNode;
+  className?: string;
+};
 
 const InlineCodeBlockSubstance = (props: InlineCodeBlockProps): JSX.Element => {
   const { children, className, ...rest } = props;
-  return <code className={`code-inline ${className ?? ''}`} {...rest}>{children}</code>;
+  return (
+    <code className={`code-inline ${className ?? ''}`} {...rest}>
+      {children}
+    </code>
+  );
 };
 
-
 function extractChildrenToIgnoreReactNode(children: ReactNode): ReactNode {
-
   if (children == null) {
     return children;
   }
@@ -37,30 +37,46 @@ function extractChildrenToIgnoreReactNode(children: ReactNode): ReactNode {
 
   // Multiple element array
   if (Array.isArray(children) && children.length > 1) {
-    return children.map(node => extractChildrenToIgnoreReactNode(node)).join('');
+    return children
+      .map((node) => extractChildrenToIgnoreReactNode(node))
+      .join('');
   }
 
   // object
   if (typeof children === 'object') {
-    const grandChildren = (children as any).children ?? (children as any).props.children;
+    const grandChildren =
+      (children as any).children ?? (children as any).props.children;
     return extractChildrenToIgnoreReactNode(grandChildren);
   }
 
   return String(children).replace(/\n$/, '');
 }
 
-function CodeBlockSubstance({ lang, children }: { lang: string, children: ReactNode }): JSX.Element {
+function CodeBlockSubstance({
+  lang,
+  children,
+}: {
+  lang: string;
+  children: ReactNode;
+}): JSX.Element {
   // return alternative element
   //   in order to fix "CodeBlock string is be [object Object] if searched"
   // see: https://github.com/growilabs/growi/pull/7484
   //
   // Note: You can also remove this code if the user requests to see the code highlighted in Prism as-is.
 
-  const isSimpleString = typeof children === 'string' || (Array.isArray(children) && children.length === 1 && typeof children[0] === 'string');
+  const isSimpleString =
+    typeof children === 'string' ||
+    (Array.isArray(children) &&
+      children.length === 1 &&
+      typeof children[0] === 'string');
   if (!isSimpleString) {
     return (
       <div style={oneDark['pre[class*="language-"]']}>
-        <code className={`language-${lang}`} style={oneDark['code[class*="language-"]']}>
+        <code
+          className={`language-${lang}`}
+          style={oneDark['code[class*="language-"]']}
+        >
           {children}
         </code>
       </div>
@@ -68,28 +84,27 @@ function CodeBlockSubstance({ lang, children }: { lang: string, children: ReactN
   }
 
   return (
-    <PrismAsyncLight
-      PreTag="div"
-      style={oneDark}
-      language={lang}
-    >
+    <PrismAsyncLight PreTag="div" style={oneDark} language={lang}>
       {extractChildrenToIgnoreReactNode(children)}
     </PrismAsyncLight>
   );
 }
 
 type CodeBlockProps = {
-  children: ReactNode,
-  className?: string,
-  inline?: true,
-}
+  children: ReactNode;
+  className?: string;
+  inline?: true;
+};
 
 export const CodeBlock = (props: CodeBlockProps): JSX.Element => {
-
   // TODO: set border according to the value of 'customize:highlightJsStyleBorder'
   const { className, children, inline } = props;
   if (inline) {
-    return <InlineCodeBlockSubstance className={`code-inline ${className ?? ''}`}>{children}</InlineCodeBlockSubstance>;
+    return (
+      <InlineCodeBlockSubstance className={`code-inline ${className ?? ''}`}>
+        {children}
+      </InlineCodeBlockSubstance>
+    );
   }
 
   const match = /language-(\w+)(:?.+)?/.exec(className || '');
@@ -99,7 +114,11 @@ export const CodeBlock = (props: CodeBlockProps): JSX.Element => {
   return (
     <>
       {name != null && (
-        <cite className={`code-highlighted-title ${styles['code-highlighted-title']}`}>{name}</cite>
+        <cite
+          className={`code-highlighted-title ${styles['code-highlighted-title']}`}
+        >
+          {name}
+        </cite>
       )}
       <CodeBlockSubstance lang={lang}>{children}</CodeBlockSubstance>
     </>

+ 37 - 18
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -1,13 +1,11 @@
 import type { JSX } from 'react';
-
-import { pagePathUtils } from '@growi/core/dist/utils';
 import type { LinkProps } from 'next/link';
 import Link from 'next/link';
+import { pagePathUtils } from '@growi/core/dist/utils';
 
 import { useSiteUrl } from '~/stores-universal/context';
 import loggerFactory from '~/utils/logger';
 
-
 const logger = loggerFactory('growi:components:NextLink');
 
 const isAnchorLink = (href: string): boolean => {
@@ -19,8 +17,7 @@ const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
     const baseUrl = new URL(siteUrl ?? 'https://example.com');
     const hrefUrl = new URL(href, baseUrl);
     return baseUrl.host !== hrefUrl.host;
-  }
-  catch (err) {
+  } catch (err) {
     logger.debug(err);
     return false;
   }
@@ -31,28 +28,26 @@ const isCreatablePage = (href: string) => {
     const url = new URL(href, 'http://example.com');
     const pathName = url.pathname;
     return pagePathUtils.isCreatablePage(pathName);
-  }
-  catch (err) {
+  } catch (err) {
     logger.debug(err);
     return false;
   }
 };
 
 type Props = Omit<LinkProps, 'href'> & {
-  children: React.ReactNode,
-  id?: string,
-  href?: string,
-  className?: string,
+  children: React.ReactNode;
+  id?: string;
+  href?: string;
+  className?: string;
 };
 
 export const NextLink = (props: Props): JSX.Element => {
-  const {
-    id, href, children, className, onClick, ...rest
-  } = props;
+  const { id, href, children, className, onClick, ...rest } = props;
 
   const { data: siteUrl } = useSiteUrl();
 
   if (href == null) {
+    // biome-ignore lint/a11y/useValidAnchor: ignore
     return <a className={className}>{children}</a>;
   }
 
@@ -63,8 +58,17 @@ export const NextLink = (props: Props): JSX.Element => {
 
   if (isExternalLink(href, siteUrl)) {
     return (
-      <a id={id} href={href} className={className} target="_blank" onClick={onClick} rel="noopener noreferrer" {...dataAttributes}>
-        {children}&nbsp;<span className="growi-custom-icons">external_link</span>
+      <a
+        id={id}
+        href={href}
+        className={className}
+        target="_blank"
+        onClick={onClick}
+        rel="noopener noreferrer"
+        {...dataAttributes}
+      >
+        {children}&nbsp;
+        <span className="growi-custom-icons">external_link</span>
       </a>
     );
   }
@@ -72,13 +76,28 @@ export const NextLink = (props: Props): JSX.Element => {
   // when href is an anchor link or not-creatable path
   if (isAnchorLink(href) || !isCreatablePage(href)) {
     return (
-      <a id={id} href={href} className={className} onClick={onClick} {...dataAttributes}>{children}</a>
+      <a
+        id={id}
+        href={href}
+        className={className}
+        onClick={onClick}
+        {...dataAttributes}
+      >
+        {children}
+      </a>
     );
   }
 
   return (
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
-      <a href={href} className={className} {...dataAttributes} onClick={onClick}>{children}</a>
+      <a
+        href={href}
+        className={className}
+        {...dataAttributes}
+        onClick={onClick}
+      >
+        {children}
+      </a>
     </Link>
   );
 };

+ 3 - 4
apps/app/src/components/Script/DrawioViewerScript/DrawioViewerScript.tsx

@@ -1,7 +1,6 @@
-import { useCallback, type JSX } from 'react';
-
-import type { IGraphViewerGlobal } from '@growi/remark-drawio';
+import { type JSX, useCallback } from 'react';
 import Head from 'next/head';
+import type { IGraphViewerGlobal } from '@growi/remark-drawio';
 
 import { useViewerMinJsUrl } from './use-viewer-min-js-url';
 
@@ -12,7 +11,7 @@ declare global {
 
 type Props = {
   drawioUri: string;
-}
+};
 
 export const DrawioViewerScript = ({ drawioUri }: Props): JSX.Element => {
   const viewerMinJsSrc = useViewerMinJsUrl(drawioUri);

+ 14 - 11
apps/app/src/components/Script/DrawioViewerScript/use-viewer-min-js-url.spec.ts

@@ -2,16 +2,19 @@ import { useViewerMinJsUrl } from './use-viewer-min-js-url';
 
 describe('useViewerMinJsUrl', () => {
   it.each`
-    drawioUri                                     | expected
-    ${'http://localhost:8080'}                    | ${'http://localhost:8080/js/viewer-static.min.js'}
-    ${'http://example.com'}                       | ${'http://example.com/js/viewer-static.min.js'}
-    ${'http://example.com/drawio'}                | ${'http://example.com/drawio/js/viewer-static.min.js'}
-    ${'http://example.com/?offline=1&https=0'}    | ${'http://example.com/js/viewer-static.min.js?offline=1&https=0'}
-  `('should return the expected URL "$expected" when drawioUri is "$drawioUrk"', ({ drawioUri, expected }: {drawioUri: string, expected: string}) => {
-    // Act
-    const url = useViewerMinJsUrl(drawioUri);
+    drawioUri                                  | expected
+    ${'http://localhost:8080'}                 | ${'http://localhost:8080/js/viewer-static.min.js'}
+    ${'http://example.com'}                    | ${'http://example.com/js/viewer-static.min.js'}
+    ${'http://example.com/drawio'}             | ${'http://example.com/drawio/js/viewer-static.min.js'}
+    ${'http://example.com/?offline=1&https=0'} | ${'http://example.com/js/viewer-static.min.js?offline=1&https=0'}
+  `(
+    'should return the expected URL "$expected" when drawioUri is "$drawioUrk"',
+    ({ drawioUri, expected }: { drawioUri: string; expected: string }) => {
+      // Act
+      const url = useViewerMinJsUrl(drawioUri);
 
-    // Assert
-    expect(url).toBe(expected);
-  });
+      // Assert
+      expect(url).toBe(expected);
+    },
+  );
 });

+ 17 - 14
apps/app/src/components/ShareLinkPageView/ShareLinkAlert.tsx

@@ -1,10 +1,10 @@
 import type { FC } from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 const generateRatio = (expiredAt: Date, createdAt: Date): number => {
-  const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
+  const wholeTime =
+    new Date(expiredAt).getTime() - new Date(createdAt).getTime();
   const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
   return remainingTime / wholeTime;
 };
@@ -14,23 +14,20 @@ const getAlertColor = (ratio: number): string => {
 
   if (ratio >= 0.75) {
     color = 'success';
-  }
-  else if (ratio < 0.75 && ratio >= 0.5) {
+  } else if (ratio < 0.75 && ratio >= 0.5) {
     color = 'info';
-  }
-  else if (ratio < 0.5 && ratio >= 0.25) {
+  } else if (ratio < 0.5 && ratio >= 0.25) {
     color = 'warning';
-  }
-  else {
+  } else {
     color = 'danger';
   }
   return color;
 };
 
 type Props = {
-  createdAt: Date,
-  expiredAt?: Date,
-}
+  createdAt: Date;
+  expiredAt?: Date;
+};
 
 const ShareLinkAlert: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
@@ -42,9 +39,15 @@ const ShareLinkAlert: FC<Props> = (props: Props) => {
   return (
     <p className={`alert alert-${alertColor} px-4 d-edit-none`}>
       <span className="material-symbols-outlined me-1">link</span>
-      {(expiredAt == null ? <span>{t('page_page.notice.no_deadline')}</span>
-      // eslint-disable-next-line react/no-danger
-        : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
+      {expiredAt == null ? (
+        <span>{t('page_page.notice.no_deadline')}</span>
+      ) : (
+        <span
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+          dangerouslySetInnerHTML={{
+            __html: t('page_page.notice.expiration', { expiredAt }),
+          }}
+        />
       )}
     </p>
   );

+ 82 - 48
apps/app/src/components/ShareLinkPageView/ShareLinkPageView.tsx

@@ -1,8 +1,7 @@
-import { useMemo, type JSX } from 'react';
-
+import { type JSX, useCallback, useMemo } from 'react';
+import dynamic from 'next/dynamic';
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
-import dynamic from 'next/dynamic';
 
 import { PagePathNavTitle } from '~/components/Common/PagePathNavTitle';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -16,31 +15,46 @@ import loggerFactory from '~/utils/logger';
 import { PageContentFooter } from '../PageView/PageContentFooter';
 import { PageViewLayout } from '../PageView/PageViewLayout';
 import RevisionRenderer from '../PageView/RevisionRenderer';
-
 import ShareLinkAlert from './ShareLinkAlert';
 
-
 const logger = loggerFactory('growi:Page');
 
-
-const PageSideContents = dynamic(() => import('~/client/components/PageSideContents').then(mod => mod.PageSideContents), { ssr: false });
-const ForbiddenPage = dynamic(() => import('~/client/components/ForbiddenPage'), { ssr: false });
-const SlideRenderer = dynamic(() => import('~/client/components/Page/SlideRenderer').then(mod => mod.SlideRenderer), { ssr: false });
+const PageSideContents = dynamic(
+  () =>
+    import('~/client/components/PageSideContents').then(
+      (mod) => mod.PageSideContents,
+    ),
+  { ssr: false },
+);
+const ForbiddenPage = dynamic(
+  () => import('~/client/components/ForbiddenPage'),
+  { ssr: false },
+);
+const SlideRenderer = dynamic(
+  () =>
+    import('~/client/components/Page/SlideRenderer').then(
+      (mod) => mod.SlideRenderer,
+    ),
+  { ssr: false },
+);
 
 type Props = {
-  pagePath: string,
-  rendererConfig: RendererConfig,
-  page?: IPagePopulatedToShowRevision,
-  shareLink?: IShareLinkHasId,
-  isExpired: boolean,
-  disableLinkSharing: boolean,
-}
+  pagePath: string;
+  rendererConfig: RendererConfig;
+  page?: IPagePopulatedToShowRevision;
+  shareLink?: IShareLinkHasId;
+  isExpired: boolean;
+  disableLinkSharing: boolean;
+};
 
 export const ShareLinkPageView = (props: Props): JSX.Element => {
   const {
-    pagePath, rendererConfig,
-    page, shareLink,
-    isExpired, disableLinkSharing,
+    pagePath,
+    rendererConfig,
+    page,
+    shareLink,
+    isExpired,
+    disableLinkSharing,
   } = props;
 
   const { data: isNotFoundMeta } = useIsNotFound();
@@ -51,7 +65,10 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
 
   const markdown = page?.revision?.body;
 
-  const isSlide = useSlidesByFrontmatter(markdown, rendererConfig.isEnabledMarp);
+  const isSlide = useSlidesByFrontmatter(
+    markdown,
+    rendererConfig.isEnabledMarp,
+  );
 
   const isNotFound = isNotFoundMeta || page == null || shareLink == null;
 
@@ -61,22 +78,19 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
     }
   }, [disableLinkSharing, props.disableLinkSharing]);
 
-  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />;
-
-  const sideContents = !isNotFound
-    ? (
-      <PageSideContents page={page} />
-    )
-    : null;
+  const headerContents = (
+    <PagePathNavTitle
+      pageId={page?._id}
+      pagePath={pagePath}
+      isWipPage={page?.wip}
+    />
+  );
 
+  const sideContents = !isNotFound ? <PageSideContents page={page} /> : null;
 
-  const footerContents = !isNotFound
-    ? (
-      <PageContentFooter page={page} />
-    )
-    : null;
+  const footerContents = !isNotFound ? <PageContentFooter page={page} /> : null;
 
-  const Contents = () => {
+  const Contents = useCallback(() => {
     if (isNotFound || page.revision == null) {
       return <></>;
     }
@@ -85,20 +99,35 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
       return (
         <>
           <h2 className="text-muted mt-4">
-            <span className="material-symbols-outlined" aria-hidden="true">block</span>
+            <span className="material-symbols-outlined" aria-hidden="true">
+              block
+            </span>
             <span> Page is expired</span>
           </h2>
         </>
       );
     }
 
-    const rendererOptions = viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
+    const rendererOptions =
+      viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
     const markdown = page.revision.body;
 
-    return isSlide != null
-      ? <SlideRenderer marp={isSlide.marp} markdown={markdown} />
-      : <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />;
-  };
+    return isSlide != null ? (
+      <SlideRenderer marp={isSlide.marp} markdown={markdown} />
+    ) : (
+      <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+    );
+  }, [
+    isExpired,
+    isSlide,
+    pagePath,
+    viewOptions,
+    page?.revision?.body,
+    rendererConfig,
+    page?.revision,
+    isNotFound,
+    isSlide?.marp,
+  ]);
 
   return (
     <PageViewLayout
@@ -107,25 +136,30 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
       expandContentWidth={shouldExpandContent}
       footerContents={footerContents}
     >
-      { specialContents }
-      { specialContents == null && (
+      {specialContents}
+      {specialContents == null && (
         <>
-          { isNotFound && (
+          {isNotFound && (
             <h2 className="text-muted mt-4">
-              <span className="material-symbols-outlined" aria-hidden="true">block</span>
+              <span className="material-symbols-outlined" aria-hidden="true">
+                block
+              </span>
               <span> Page is not found</span>
             </h2>
-          ) }
-          { !isNotFound && (
+          )}
+          {!isNotFound && (
             <>
-              <ShareLinkAlert expiredAt={shareLink.expiredAt} createdAt={shareLink.createdAt} />
+              <ShareLinkAlert
+                expiredAt={shareLink.expiredAt}
+                createdAt={shareLink.createdAt}
+              />
               <div className="mb-5">
                 <Contents />
               </div>
             </>
-          ) }
+          )}
         </>
-      ) }
+      )}
     </PageViewLayout>
   );
 };

+ 0 - 4
apps/app/src/components/User/UserDate.jsx

@@ -1,16 +1,13 @@
 import React from 'react';
-
 import { format } from 'date-fns/format';
 import PropTypes from 'prop-types';
 
-
 /**
  * UserDate
  *
  * display date depends on user timezone of user settings
  */
 export default class UserDate extends React.Component {
-
   render() {
     const date = new Date(this.props.dateTime);
     const dt = format(date, this.props.format);
@@ -21,7 +18,6 @@ export default class UserDate extends React.Component {
       </span>
     );
   }
-
 }
 
 UserDate.propTypes = {

+ 17 - 18
apps/app/src/components/User/UserInfo.tsx

@@ -1,17 +1,14 @@
 import React, { type JSX } from 'react';
-
 import type { IUserHasId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 
 import styles from './UserInfo.module.scss';
 
-
 export type UserInfoProps = {
-  author?: IUserHasId,
-}
+  author?: IUserHasId;
+};
 
 export const UserInfo = (props: UserInfoProps): JSX.Element => {
-
   const { author } = props;
 
   if (author == null || author.status === 4) {
@@ -19,29 +16,31 @@ export const UserInfo = (props: UserInfoProps): JSX.Element => {
   }
 
   return (
-    <div className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`} data-testid="grw-users-info">
+    <div
+      className={`${styles['grw-users-info']} grw-users-info d-flex align-items-center d-edit-none mb-5 pb-3 border-bottom`}
+      data-testid="grw-users-info"
+    >
       <UserPicture user={author} noTooltip noLink />
       <div className="users-meta">
-        <h1 className="user-page-name">
-          {author.name}
-        </h1>
+        <h1 className="user-page-name">{author.name}</h1>
         <div className="user-page-meta mt-3 mb-0">
           <span className="user-page-username me-4">
-            <span className="user-page-username me-4"><span className="material-symbols-outlined">person</span>{author.username}</span>
+            <span className="user-page-username me-4">
+              <span className="material-symbols-outlined">person</span>
+              {author.username}
+            </span>
           </span>
           <span className="user-page-email me-2">
             <span className="material-symbols-outlined me-1">mail</span>
-            { author.isEmailPublished
-              ? author.email
-              : '*****'
-            }
+            {author.isEmailPublished ? author.email : '*****'}
           </span>
-          { author.introduction && (
-            <span className="user-page-introduction">{author.introduction}</span>
-          ) }
+          {author.introduction && (
+            <span className="user-page-introduction">
+              {author.introduction}
+            </span>
+          )}
         </div>
       </div>
     </div>
   );
-
 };

+ 7 - 6
apps/app/src/components/User/Username.tsx

@@ -1,12 +1,13 @@
-import React, { type JSX } from 'react';
-
+import type React from 'react';
+import type { JSX } from 'react';
+import Link from 'next/link';
 import type { IUserHasId } from '@growi/core';
-import { isPopulated, type IUser, type Ref } from '@growi/core';
+import { type IUser, isPopulated, type Ref } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
-import Link from 'next/link';
-
-export const Username: React.FC<{ user?: IUserHasId | Ref<IUser> }> = ({ user }): JSX.Element => {
 
+export const Username: React.FC<{ user?: IUserHasId | Ref<IUser> }> = ({
+  user,
+}): JSX.Element => {
   if (user == null || !isPopulated(user)) {
     return <i>(anyone)</i>;
   }

+ 4 - 3
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -1,7 +1,7 @@
-import type { IGrantedGroup } from '@growi/core';
-import { GroupType, getIdForRef } from '@growi/core';
 import type { FC } from 'react';
 import { useCallback, useMemo, useState } from 'react';
+import type { IGrantedGroup } from '@growi/core';
+import { GroupType, getIdForRef } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { TabContent, TabPane } from 'reactstrap';
 
@@ -21,7 +21,6 @@ import {
   useSWRxExternalUserGroupList,
   useSWRxExternalUserGroupRelationList,
 } from '../../stores/external-user-group';
-
 import { KeycloakGroupManagement } from './KeycloakGroupManagement';
 import { LdapGroupManagement } from './LdapGroupManagement';
 
@@ -168,12 +167,14 @@ export const ExternalGroupManagement: FC = () => {
   const navTabMapping = useMemo(() => {
     return {
       ldap: {
+        // biome-ignore lint/correctness/noNestedComponentDefinitions: ignore
         Icon: () => (
           <span className="material-symbols-outlined">network_node</span>
         ),
         i18n: 'LDAP',
       },
       keycloak: {
+        // biome-ignore lint/correctness/noNestedComponentDefinitions: ignore
         Icon: () => <span className="material-symbols-outlined">key</span>,
         i18n: 'Keycloak',
       },

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

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import { useCallback, useEffect, useState } from 'react';
-
 import { useTranslation } from 'react-i18next';
 
 import { apiv3Put } from '~/client/util/apiv3-client';

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

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
-import { type JSX, useCallback, useEffect, useState } from 'react';
-
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
@@ -39,6 +38,7 @@ export const LdapGroupManagement: FC = () => {
     [isUserBind],
   );
 
+  // biome-ignore lint/correctness/noNestedComponentDefinitions: ignore
   const AdditionalForm = (): JSX.Element => {
     return isUserBind ? (
       <div className="row form-group">

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

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import { useCallback, useEffect, useState } from 'react';
-
 import { useTranslation } from 'react-i18next';
 
 import { apiv3Put } from '~/client/util/apiv3-client';

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

@@ -1,6 +1,5 @@
 import type { FC, JSX } from 'react';
 import { useCallback, useEffect, useState } from 'react';
-
 import { useTranslation } from 'react-i18next';
 import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 

+ 0 - 1
apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts

@@ -6,7 +6,6 @@ import UserGroupRelation from '~/server/models/user-group-relation';
 
 import { getOrCreateModel } from '../../../../server/util/mongoose-utils';
 import type { IExternalUserGroupRelation } from '../../interfaces/external-user-group';
-
 import type { ExternalUserGroupDocument } from './external-user-group';
 
 export interface ExternalUserGroupRelationDocument

+ 1 - 0
apps/app/src/features/external-user-group/server/routes/apiv3/external-user-group-relation.ts

@@ -1,6 +1,7 @@
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
+
 import type { IExternalUserGroupRelationHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import type Crowi from '~/server/crowi';

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio