Procházet zdrojové kódy

Merge branch 'master' into support/156162-164870-app-some-client-components-biome-8

Yuki Takei před 3 měsíci
rodič
revize
115a65fabd
100 změnil soubory, kde provedl 5937 přidání a 3221 odebrání
  1. 23 0
      apps/app/.eslintrc.js
  2. 1 1
      apps/app/package.json
  3. 5 0
      apps/app/public/static/locales/en_US/translation.json
  4. 5 0
      apps/app/public/static/locales/fr_FR/translation.json
  5. 5 0
      apps/app/public/static/locales/ja_JP/translation.json
  6. 5 0
      apps/app/public/static/locales/ko_KR/translation.json
  7. 5 0
      apps/app/public/static/locales/zh_CN/translation.json
  8. 32 13
      apps/app/src/client/components/Admin/AuditLog/ActivityTable.tsx
  9. 10 4
      apps/app/src/client/components/Admin/AuditLog/AuditLogDisableMode.tsx
  10. 38 14
      apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx
  11. 49 44
      apps/app/src/client/components/Admin/AuditLog/DateRangePicker.tsx
  12. 54 26
      apps/app/src/client/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  13. 151 88
      apps/app/src/client/components/Admin/AuditLog/SelectActionDropdown.tsx
  14. 8 10
      apps/app/src/client/components/Admin/Customize/Customize.jsx
  15. 36 28
      apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx
  16. 7 11
      apps/app/src/client/components/Admin/Customize/CustomizeFunctionOption.tsx
  17. 105 44
      apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx
  18. 36 21
      apps/app/src/client/components/Admin/Customize/CustomizeLayoutSetting.tsx
  19. 100 46
      apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx
  20. 54 38
      apps/app/src/client/components/Admin/Customize/CustomizeNoscriptSetting.tsx
  21. 36 18
      apps/app/src/client/components/Admin/Customize/CustomizePresentationSetting.tsx
  22. 52 38
      apps/app/src/client/components/Admin/Customize/CustomizeScriptSetting.tsx
  23. 32 27
      apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx
  24. 19 15
      apps/app/src/client/components/Admin/Customize/CustomizeThemeOptions.tsx
  25. 25 19
      apps/app/src/client/components/Admin/Customize/CustomizeThemeSetting.tsx
  26. 62 30
      apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx
  27. 12 11
      apps/app/src/client/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx
  28. 52 24
      apps/app/src/client/components/Admin/Customize/ThemeColorBox.tsx
  29. 71 24
      apps/app/src/client/components/Admin/Notification/GlobalNotification.jsx
  30. 90 40
      apps/app/src/client/components/Admin/Notification/GlobalNotificationList.jsx
  31. 160 102
      apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx
  32. 18 11
      apps/app/src/client/components/Admin/Notification/NotificationDeleteModal.jsx
  33. 93 54
      apps/app/src/client/components/Admin/Notification/NotificationSetting.jsx
  34. 8 6
      apps/app/src/client/components/Admin/Notification/NotificationTypeIcon.tsx
  35. 5 5
      apps/app/src/client/components/Admin/Notification/TriggerEventCheckBox.jsx
  36. 18 13
      apps/app/src/client/components/Admin/Notification/UserNotificationRow.jsx
  37. 66 29
      apps/app/src/client/components/Admin/Notification/UserTriggerNotification.jsx
  38. 7 10
      apps/app/src/client/components/Admin/Security/DeleteAllShareLinksModal.jsx
  39. 11 13
      apps/app/src/client/components/Admin/Security/GitHubSecuritySetting.jsx
  40. 162 58
      apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx
  41. 11 13
      apps/app/src/client/components/Admin/Security/GoogleSecuritySetting.jsx
  42. 175 60
      apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx
  43. 51 31
      apps/app/src/client/components/Admin/Security/LdapAuthTest.tsx
  44. 1 12
      apps/app/src/client/components/Admin/Security/LdapAuthTestModal.jsx
  45. 10 11
      apps/app/src/client/components/Admin/Security/LdapSecuritySetting.jsx
  46. 323 136
      apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx
  47. 10 12
      apps/app/src/client/components/Admin/Security/LocalSecuritySetting.jsx
  48. 117 55
      apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx
  49. 10 11
      apps/app/src/client/components/Admin/Security/OidcSecuritySetting.jsx
  50. 398 108
      apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx
  51. 10 11
      apps/app/src/client/components/Admin/Security/SamlSecuritySetting.jsx
  52. 369 116
      apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx
  53. 10 9
      apps/app/src/client/components/Admin/Security/SecurityManagement.tsx
  54. 65 42
      apps/app/src/client/components/Admin/Security/SecurityManagementContents.jsx
  55. 20 7
      apps/app/src/client/components/Admin/Security/SecuritySetting/CommentManageRightsSettings.tsx
  56. 19 7
      apps/app/src/client/components/Admin/Security/SecuritySetting/PageAccessRightsSettings.tsx
  57. 321 170
      apps/app/src/client/components/Admin/Security/SecuritySetting/PageDeleteRightsSettings.tsx
  58. 39 16
      apps/app/src/client/components/Admin/Security/SecuritySetting/PageListDisplaySettings.tsx
  59. 15 5
      apps/app/src/client/components/Admin/Security/SecuritySetting/SessionMaxAgeSettings.tsx
  60. 44 13
      apps/app/src/client/components/Admin/Security/SecuritySetting/UserHomepageDeletionSettings.tsx
  61. 81 37
      apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx
  62. 22 7
      apps/app/src/client/components/Admin/Security/SecuritySetting/types.ts
  63. 71 57
      apps/app/src/client/components/Admin/Security/ShareLinkSetting.tsx
  64. 224 147
      apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx
  65. 27 19
      apps/app/src/client/components/Bookmarks/BookmarkFolderItemControl.tsx
  66. 85 60
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx
  67. 11 12
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenuItem.tsx
  68. 39 19
      apps/app/src/client/components/Bookmarks/BookmarkFolderNameInput.tsx
  69. 66 33
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.tsx
  70. 173 114
      apps/app/src/client/components/Bookmarks/BookmarkItem.tsx
  71. 39 19
      apps/app/src/client/components/Bookmarks/BookmarkItemRenameInput.tsx
  72. 5 4
      apps/app/src/client/components/Bookmarks/BookmarkMoveToRootBtn.tsx
  73. 33 21
      apps/app/src/client/components/Bookmarks/DragAndDropWrapper.tsx
  74. 4 4
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx
  75. 72 36
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx
  76. 4 1
      apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx
  77. 51 31
      apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx
  78. 14 12
      apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx
  79. 8 9
      apps/app/src/client/components/InAppNotification/InAppNotificationList.tsx
  80. 105 80
      apps/app/src/client/components/InAppNotification/InAppNotificationPage.tsx
  81. 7 9
      apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx
  82. 42 20
      apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx
  83. 15 14
      apps/app/src/client/components/InAppNotification/ModelNotification/PageModelNotification.tsx
  84. 10 10
      apps/app/src/client/components/InAppNotification/ModelNotification/UserModelNotification.tsx
  85. 13 11
      apps/app/src/client/components/InAppNotification/ModelNotification/index.tsx
  86. 6 4
      apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts
  87. 0 2
      apps/app/src/client/components/ItemsTree/ItemsTreeContentSkeleton.tsx
  88. 21 9
      apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx
  89. 34 20
      apps/app/src/client/components/LoginForm/LoginForm.spec.tsx
  90. 299 183
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  91. 130 112
      apps/app/src/client/components/Me/AccessTokenForm.tsx
  92. 76 68
      apps/app/src/client/components/Me/AccessTokenList.tsx
  93. 16 8
      apps/app/src/client/components/Me/AccessTokenScopeList.tsx
  94. 20 6
      apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx
  95. 107 82
      apps/app/src/client/components/Me/AccessTokenSettings.tsx
  96. 8 6
      apps/app/src/client/components/Me/ApiSettings.tsx
  97. 34 39
      apps/app/src/client/components/Me/ApiTokenSettings.tsx
  98. 59 35
      apps/app/src/client/components/Me/AssociateModal.tsx
  99. 110 57
      apps/app/src/client/components/Me/BasicInfoSettings.tsx
  100. 50 24
      apps/app/src/client/components/Me/ColorModeSettings.tsx

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

@@ -37,6 +37,20 @@ module.exports = {
     'src/interfaces/**',
     'src/utils/**',
     'src/components/**',
+    'src/client/components/DescendantsPageListModal/**',
+    'src/client/components/ItemsTree/**',
+    'src/client/components/LoginForm/**',
+    'src/client/components/Page/**',
+    'src/client/components/PageAttachment/**',
+    'src/client/components/PageDeleteModal/**',
+    'src/client/components/PageDuplicateModal/**',
+    'src/client/components/PageList/**',
+    'src/client/components/PageManagement/**',
+    'src/client/components/PagePathNavSticky/**',
+    'src/client/components/PagePresentationModal/**',
+    'src/client/components/PageRenameModal/**',
+    'src/client/components/PageSelectModal/**',
+    'src/client/components/PageSideContents/**',
     'src/client/components/*.tsx',
     'src/client/components/*.jsx',
     'src/client/components/*.ts',
@@ -52,10 +66,19 @@ module.exports = {
     'src/client/components/Admin/LegacySlackIntegration/**',
     'src/client/components/Admin/MarkdownSetting/**',
     'src/client/components/Admin/App/**',
+    'src/client/components/Admin/AuditLog/**',
+    'src/client/components/Admin/Customize/**',
+    'src/client/components/Admin/Notification/**',
+    'src/client/components/Admin/Security/**',
     'src/client/components/Admin/SlackIntegration/**',
     'src/client/components/Admin/Users/**',
     'src/client/components/Admin/UserGroup/**',
     'src/client/components/Admin/UserGroupDetail/**',
+    'src/client/components/Me/**',
+    'src/client/components/Bookmarks/**',
+    'src/client/components/InAppNotification/**',
+    'src/client/components/PageTags/**',
+    'src/client/components/ReactMarkdownComponents/**',
     'src/client/components/AuthorInfo/**',
     'src/client/components/Common/**',
     'src/client/components/CreateTemplateModal/**',

+ 1 - 1
apps/app/package.json

@@ -193,7 +193,7 @@
     "passport-saml": "^3.2.0",
     "pathe": "^2.0.3",
     "prop-types": "^15.8.1",
-    "qs": "^6.11.1",
+    "qs": "^6.14.1",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 52 - 24
apps/app/src/client/components/Admin/Customize/ThemeColorBox.tsx

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 18 - 13
apps/app/src/client/components/Admin/Notification/UserNotificationRow.jsx

@@ -1,16 +1,13 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import { NotificationTypeIcon } from './NotificationTypeIcon';
 
 class UserNotificationRow extends React.PureComponent {
-
   render() {
     const { t, notification } = this.props;
     const id = `user-notification-${notification._id}`;
@@ -18,26 +15,32 @@ class UserNotificationRow extends React.PureComponent {
     return (
       <React.Fragment>
         <tr className="admin-notif-row" key={id}>
+          <td className="px-4">{notification.pathPattern}</td>
           <td className="px-4">
-            {notification.pathPattern}
-          </td>
-          <td className="px-4">
-            <NotificationTypeIcon notification={notification} />{notification.channel}
+            <NotificationTypeIcon notification={notification} />
+            {notification.channel}
           </td>
           <td>
-            <button type="submit" className="btn btn-outline-danger" onClick={() => { this.props.onClickDeleteBtn(notification._id) }}>{t('Delete')}</button>
+            <button
+              type="submit"
+              className="btn btn-outline-danger"
+              onClick={() => {
+                this.props.onClickDeleteBtn(notification._id);
+              }}
+            >
+              {t('Delete')}
+            </button>
           </td>
         </tr>
       </React.Fragment>
     );
-
   }
-
 }
 
 UserNotificationRow.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
+  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer)
+    .isRequired,
 
   notification: PropTypes.object.isRequired,
   onClickDeleteBtn: PropTypes.func.isRequired,
@@ -50,7 +53,9 @@ const UserNotificationRowWrapperWrapperFC = (props) => {
   return <UserNotificationRow t={t} {...props} />;
 };
 
-const UserNotificationRowWrapper = withUnstatedContainers(UserNotificationRowWrapperWrapperFC, [AdminNotificationContainer]);
-
+const UserNotificationRowWrapper = withUnstatedContainers(
+  UserNotificationRowWrapperWrapperFC,
+  [AdminNotificationContainer],
+);
 
 export default UserNotificationRowWrapper;

+ 66 - 29
apps/app/src/client/components/Admin/Notification/UserTriggerNotification.jsx

@@ -1,20 +1,17 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import UserNotificationRow from './UserNotificationRow';
 
 const logger = loggerFactory('growi:slackAppConfiguration');
 
 class UserTriggerNotification extends React.Component {
-
   constructor(props) {
     super(props);
 
@@ -28,7 +25,6 @@ class UserTriggerNotification extends React.Component {
     this.validateForm = this.validateForm.bind(this);
     this.onClickSubmit = this.onClickSubmit.bind(this);
     this.onClickDeleteBtn = this.onClickDeleteBtn.bind(this);
-
   }
 
   /**
@@ -53,11 +49,13 @@ class UserTriggerNotification extends React.Component {
     const { t, adminNotificationContainer } = this.props;
 
     try {
-      await adminNotificationContainer.addNotificationPattern(this.state.pathPattern, this.state.channel);
+      await adminNotificationContainer.addNotificationPattern(
+        this.state.pathPattern,
+        this.state.channel,
+      );
       toastSuccess(t('notification_settings.add_notification_pattern'));
       this.setState({ pathPattern: '', channel: '' });
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       logger.error(err);
     }
@@ -67,10 +65,16 @@ class UserTriggerNotification extends React.Component {
     const { t, adminNotificationContainer } = this.props;
 
     try {
-      const deletedNotificaton = await adminNotificationContainer.deleteUserTriggerNotificationPattern(notificationIdForDelete);
-      toastSuccess(t('notification_settings.delete_notification_pattern', { path: deletedNotificaton.pathPattern }));
-    }
-    catch (err) {
+      const deletedNotificaton =
+        await adminNotificationContainer.deleteUserTriggerNotificationPattern(
+          notificationIdForDelete,
+        );
+      toastSuccess(
+        t('notification_settings.delete_notification_pattern', {
+          path: deletedNotificaton.pathPattern,
+        }),
+      );
+    } catch (err) {
       toastError(err);
       logger.error(err);
     }
@@ -78,11 +82,14 @@ class UserTriggerNotification extends React.Component {
 
   render() {
     const { t, adminNotificationContainer } = this.props;
-    const userNotifications = adminNotificationContainer.state.userNotifications || [];
+    const userNotifications =
+      adminNotificationContainer.state.userNotifications || [];
 
     return (
       <React.Fragment>
-        <h2 className="border-bottom my-4">{t('notification_settings.user_trigger_notification_header')}</h2>
+        <h2 className="border-bottom my-4">
+          {t('notification_settings.user_trigger_notification_header')}
+        </h2>
 
         <table className="table table-bordered">
           <thead>
@@ -101,18 +108,27 @@ class UserTriggerNotification extends React.Component {
                   name="pathPattern"
                   value={this.state.pathPattern}
                   placeholder="e.g. /projects/xxx/MTG/*"
-                  onChange={(e) => { this.changePathPattern(e.target.value) }}
+                  onChange={(e) => {
+                    this.changePathPattern(e.target.value);
+                  }}
                 />
                 <p className="p-2 mb-0">
                   {/* eslint-disable-next-line react/no-danger */}
-                  <span dangerouslySetInnerHTML={{ __html: t('notification_settings.pattern_desc') }} />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t('notification_settings.pattern_desc'),
+                    }}
+                  />
                 </p>
               </td>
 
               <td>
                 <div className="input-group notify-to-option" id="slack-input">
                   <div>
-                    <span className="input-group-text"><span className="material-symbols-outlined">tag</span></span>
+                    <span className="input-group-text">
+                      <span className="material-symbols-outlined">tag</span>
+                    </span>
                   </div>
                   <input
                     className="form-control"
@@ -120,35 +136,53 @@ class UserTriggerNotification extends React.Component {
                     name="channel"
                     value={this.state.channel}
                     placeholder="e.g. project-xxx"
-                    onChange={(e) => { this.changeChannel(e.target.value) }}
+                    onChange={(e) => {
+                      this.changeChannel(e.target.value);
+                    }}
                   />
                 </div>
                 <p className="p-2 mb-0">
                   {/* eslint-disable-next-line react/no-danger */}
-                  <span dangerouslySetInnerHTML={{ __html: t('notification_settings.channel_desc') }} />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t('notification_settings.channel_desc'),
+                    }}
+                  />
                 </p>
               </td>
               <td>
-                <button type="button" className="btn btn-primary" disabled={!this.validateForm()} onClick={this.onClickSubmit}>{t('commons:Add')}</button>
+                <button
+                  type="button"
+                  className="btn btn-primary"
+                  disabled={!this.validateForm()}
+                  onClick={this.onClickSubmit}
+                >
+                  {t('commons:Add')}
+                </button>
               </td>
             </tr>
-            {userNotifications.length > 0 && userNotifications.map((notification) => {
-              return <UserNotificationRow notification={notification} onClickDeleteBtn={this.onClickDeleteBtn} key={notification._id} />;
-            })}
+            {userNotifications.length > 0 &&
+              userNotifications.map((notification) => {
+                return (
+                  <UserNotificationRow
+                    notification={notification}
+                    onClickDeleteBtn={this.onClickDeleteBtn}
+                    key={notification._id}
+                  />
+                );
+              })}
           </tbody>
         </table>
       </React.Fragment>
     );
   }
-
-
 }
 
-
 UserTriggerNotification.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer).isRequired,
-
+  adminNotificationContainer: PropTypes.instanceOf(AdminNotificationContainer)
+    .isRequired,
 };
 
 const UserTriggerNotificationWrapperFC = (props) => {
@@ -157,6 +191,9 @@ const UserTriggerNotificationWrapperFC = (props) => {
   return <UserTriggerNotification t={t} {...props} />;
 };
 
-const UserTriggerNotificationWrapper = withUnstatedContainers(UserTriggerNotificationWrapperFC, [AdminNotificationContainer]);
+const UserTriggerNotificationWrapper = withUnstatedContainers(
+  UserTriggerNotificationWrapperFC,
+  [AdminNotificationContainer],
+);
 
 export default UserTriggerNotificationWrapper;

+ 7 - 10
apps/app/src/client/components/Admin/Security/DeleteAllShareLinksModal.jsx

@@ -1,10 +1,7 @@
 import React, { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 const DeleteAllShareLinksModal = React.memo((props) => {
   const { t, onClickDeleteButton, onClose } = props;
@@ -19,16 +16,18 @@ const DeleteAllShareLinksModal = React.memo((props) => {
   }, [onClose]);
 
   return (
-    <Modal isOpen={props.isOpen} toggle={closeButtonHandler} className="page-comment-delete-modal">
+    <Modal
+      isOpen={props.isOpen}
+      toggle={closeButtonHandler}
+      className="page-comment-delete-modal"
+    >
       <ModalHeader tag="h4" toggle={closeButtonHandler} className="text-danger">
         <span>
           <span className="material-symbols-outlined">delete_forever</span>
           {t('security_settings.delete_all_share_links')}
         </span>
       </ModalHeader>
-      <ModalBody>
-        { t('security_settings.share_link_notice')}
-      </ModalBody>
+      <ModalBody>{t('security_settings.share_link_notice')}</ModalBody>
       <ModalFooter>
         <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
         <Button color="danger" onClick={deleteAllLinkHandler}>
@@ -38,11 +37,9 @@ const DeleteAllShareLinksModal = React.memo((props) => {
       </ModalFooter>
     </Modal>
   );
-
 });
 DeleteAllShareLinksModal.displayName = 'DeleteAllShareLinksModal';
 
-
 DeleteAllShareLinksModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 

+ 11 - 13
apps/app/src/client/components/Admin/Security/GitHubSecuritySetting.jsx

@@ -1,5 +1,4 @@
-import React, { useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect } from 'react';
 import PropTypes from 'prop-types';
 
 import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
@@ -7,18 +6,15 @@ import { toastError } from '~/client/util/toastr';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
-
 import GitHubSecuritySettingContents from './GitHubSecuritySettingContents';
 
 const GitHubSecurityManagement = (props) => {
   const { adminGitHubSecurityContainer } = props;
 
-  const fetchGitHubSecuritySettingsData = useCallback(async() => {
+  const fetchGitHubSecuritySettingsData = useCallback(async () => {
     try {
       await adminGitHubSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
     }
@@ -26,18 +22,20 @@ const GitHubSecurityManagement = (props) => {
 
   useEffect(() => {
     fetchGitHubSecuritySettingsData();
-  }, [adminGitHubSecurityContainer, fetchGitHubSecuritySettingsData]);
+  }, [fetchGitHubSecuritySettingsData]);
 
   return <GitHubSecuritySettingContents />;
 };
 
-
 GitHubSecurityManagement.propTypes = {
-  adminGitHubSecurityContainer: PropTypes.instanceOf(AdminGitHubSecurityContainer).isRequired,
+  adminGitHubSecurityContainer: PropTypes.instanceOf(
+    AdminGitHubSecurityContainer,
+  ).isRequired,
 };
 
-const GitHubSecurityManagementWithUnstatedContainer = withUnstatedContainers(GitHubSecurityManagement, [
-  AdminGitHubSecurityContainer,
-]);
+const GitHubSecurityManagementWithUnstatedContainer = withUnstatedContainers(
+  GitHubSecurityManagement,
+  [AdminGitHubSecurityContainer],
+);
 
 export default GitHubSecurityManagementWithUnstatedContainer;

+ 162 - 58
apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx

@@ -1,35 +1,35 @@
 /* eslint-disable react/no-danger */
 import React, { useCallback, useEffect } 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 AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 type Props = {
-  adminGeneralSecurityContainer: AdminGeneralSecurityContainer
-  adminGitHubSecurityContainer: AdminGitHubSecurityContainer
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  adminGitHubSecurityContainer: AdminGitHubSecurityContainer;
 };
 
 const GitHubSecurityManagementContents = (props: Props) => {
-  const {
-    adminGeneralSecurityContainer, adminGitHubSecurityContainer,
-  } = props;
+  const { adminGeneralSecurityContainer, adminGitHubSecurityContainer } = props;
 
   const { t } = useTranslation('admin');
   const siteUrl = useSiteUrlWithEmptyValueWarn();
 
   const { isGitHubEnabled } = adminGeneralSecurityContainer.state;
-  const { githubClientId, githubClientSecret, retrieveError } = adminGitHubSecurityContainer.state;
-  const gitHubCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/github/callback');
+  const { githubClientId, githubClientSecret, retrieveError } =
+    adminGitHubSecurityContainer.state;
+  const gitHubCallbackUrl = urljoin(
+    pathUtils.removeTrailingSlash(siteUrl),
+    '/passport/github/callback',
+  );
 
   const { register, handleSubmit, reset } = useForm();
 
@@ -41,32 +41,37 @@ const GitHubSecurityManagementContents = (props: Props) => {
     });
   }, [reset, githubClientId, githubClientSecret]);
 
-  const onClickSubmit = useCallback(async(data) => {
-    try {
-      await adminGitHubSecurityContainer.updateGitHubSetting({
-        githubClientId: data.githubClientId ?? '',
-        githubClientSecret: data.githubClientSecret ?? '',
-        isSameUsernameTreatedAsIdenticalUser: adminGitHubSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser,
-      });
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_settings.OAuth.GitHub.updated_github'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminGitHubSecurityContainer, adminGeneralSecurityContainer, t]);
+  const onClickSubmit = useCallback(
+    async (data) => {
+      try {
+        await adminGitHubSecurityContainer.updateGitHubSetting({
+          githubClientId: data.githubClientId ?? '',
+          githubClientSecret: data.githubClientSecret ?? '',
+          isSameUsernameTreatedAsIdenticalUser:
+            adminGitHubSecurityContainer.state
+              .isSameUsernameTreatedAsIdenticalUser,
+        });
+        await adminGeneralSecurityContainer.retrieveSetupStratedies();
+        toastSuccess(t('security_settings.OAuth.GitHub.updated_github'));
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [adminGitHubSecurityContainer, adminGeneralSecurityContainer, t],
+  );
 
   return (
     <form onSubmit={handleSubmit(onClickSubmit)}>
       <React.Fragment>
-
         <h2 className="alert-anchor border-bottom">
           {t('security_settings.OAuth.GitHub.name')}
         </h2>
 
         {retrieveError != null && (
           <div className="alert alert-danger">
-            <p>{t('Error occurred')} : {retrieveError}</p>
+            <p>
+              {t('Error occurred')} : {retrieveError}
+            </p>
           </div>
         )}
 
@@ -77,47 +82,81 @@ const GitHubSecurityManagementContents = (props: Props) => {
                 id="isGitHubEnabled"
                 className="form-check-input"
                 type="checkbox"
-                checked={adminGeneralSecurityContainer.state.isGitHubEnabled || false}
-                onChange={() => { adminGeneralSecurityContainer.switchIsGitHubOAuthEnabled() }}
+                checked={
+                  adminGeneralSecurityContainer.state.isGitHubEnabled || false
+                }
+                onChange={() => {
+                  adminGeneralSecurityContainer.switchIsGitHubOAuthEnabled();
+                }}
               />
-              <label className="form-label form-check-label" htmlFor="isGitHubEnabled">
+              <label
+                className="form-label form-check-label"
+                htmlFor="isGitHubEnabled"
+              >
                 {t('security_settings.OAuth.GitHub.enable_github')}
               </label>
             </div>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('github') && isGitHubEnabled)
-              && <div className="badge text-bg-warning">{t('security_settings.setup_is_not_yet_complete')}</div>}
+            {!adminGeneralSecurityContainer.state.setupStrategies.includes(
+              'github',
+            ) &&
+              isGitHubEnabled && (
+                <div className="badge text-bg-warning">
+                  {t('security_settings.setup_is_not_yet_complete')}
+                </div>
+              )}
           </div>
         </div>
 
         <div className="row mb-4">
-          <label className="form-label col-12 col-md-3 text-start text-md-end py-2">{t('security_settings.callback_URL')}</label>
+          <label
+            className="form-label col-12 col-md-3 text-start text-md-end py-2"
+            htmlFor="gitHubCallbackUrl"
+          >
+            {t('security_settings.callback_URL')}
+          </label>
           <div className="col-12 col-md-6">
             <input
+              id="gitHubCallbackUrl"
               className="form-control"
               type="text"
               value={gitHubCallbackUrl}
               readOnly
             />
-            <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
+            <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' }) }}
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
 
-
         {isGitHubEnabled && (
           <React.Fragment>
-
-            <h3 className="border-bottom mb-4">{t('security_settings.configuration')}</h3>
+            <h3 className="border-bottom mb-4">
+              {t('security_settings.configuration')}
+            </h3>
 
             <div className="row mb-4">
-              <label htmlFor="githubClientId" className="col-3 text-end py-2 form-label">{t('security_settings.clientID')}</label>
+              <label
+                htmlFor="githubClientId"
+                className="col-3 text-end py-2 form-label"
+              >
+                {t('security_settings.clientID')}
+              </label>
               <div className="col-6">
                 <input
                   className="form-control"
@@ -125,13 +164,25 @@ const GitHubSecurityManagementContents = (props: Props) => {
                   {...register('githubClientId')}
                 />
                 <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_ID' }) }} />
+                  <small
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t('security_settings.Use env var if empty', {
+                        env: 'OAUTH_GITHUB_CLIENT_ID',
+                      }),
+                    }}
+                  />
                 </p>
               </div>
             </div>
 
             <div className="row mb-3">
-              <label htmlFor="githubClientSecret" className="col-3 text-end py-2 form-label">{t('security_settings.client_secret')}</label>
+              <label
+                htmlFor="githubClientSecret"
+                className="col-3 text-end py-2 form-label"
+              >
+                {t('security_settings.client_secret')}
+              </label>
               <div className="col-6">
                 <input
                   className="form-control"
@@ -139,7 +190,14 @@ const GitHubSecurityManagementContents = (props: Props) => {
                   {...register('githubClientSecret')}
                 />
                 <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GITHUB_CLIENT_SECRET' }) }} />
+                  <small
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t('security_settings.Use env var if empty', {
+                        env: 'OAUTH_GITHUB_CLIENT_SECRET',
+                      }),
+                    }}
+                  />
                 </p>
               </div>
             </div>
@@ -151,29 +209,52 @@ const GitHubSecurityManagementContents = (props: Props) => {
                     id="bindByUserNameGitHub"
                     className="form-check-input"
                     type="checkbox"
-                    checked={adminGitHubSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
-                    onChange={() => { adminGitHubSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                    checked={
+                      adminGitHubSecurityContainer.state
+                        .isSameUsernameTreatedAsIdenticalUser || false
+                    }
+                    onChange={() => {
+                      adminGitHubSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser();
+                    }}
                   />
                   <label
                     className="form-check-label"
                     htmlFor="bindByUserNameGitHub"
-                    dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical') }}
-                  />
+                  >
+                    <span
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      dangerouslySetInnerHTML={{
+                        __html: t(
+                          'security_settings.Treat email matching as identical',
+                        ),
+                      }}
+                    />
+                  </label>
                 </div>
                 <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical_warn') }} />
+                  <small
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'security_settings.Treat email matching as identical_warn',
+                      ),
+                    }}
+                  />
                 </p>
               </div>
             </div>
 
             <div className="row mb-4">
               <div className="offset-3 col-5">
-                <button type="submit" className="btn btn-primary" disabled={retrieveError != null}>
+                <button
+                  type="submit"
+                  className="btn btn-primary"
+                  disabled={retrieveError != null}
+                >
                   {t('Update')}
                 </button>
               </div>
             </div>
-
           </React.Fragment>
         )}
 
@@ -181,19 +262,42 @@ const GitHubSecurityManagementContents = (props: Props) => {
 
         <div style={{ minHeight: '300px' }}>
           <h4>
-            <span className="material-symbols-outlined" aria-hidden="true">help</span>
-            <a href="#collapseHelpForGitHubOauth" data-bs-toggle="collapse"> {t('security_settings.OAuth.how_to.github')}</a>
+            <span className="material-symbols-outlined" aria-hidden="true">
+              help
+            </span>
+            <a href="#collapseHelpForGitHubOauth" data-bs-toggle="collapse">
+              {' '}
+              {t('security_settings.OAuth.how_to.github')}
+            </a>
           </h4>
           <div className="card custom-card bg-body-tertiary">
             <ol id="collapseHelpForGitHubOauth" className="collapse mb-0">
               {/* eslint-disable-next-line max-len */}
-              <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.GitHub.register_1', { link: '<a href="https://github.com/settings/developers" target=_blank>GitHub Developer Settings</a>' }) }} />
-              <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.GitHub.register_2', { url: gitHubCallbackUrl }) }} />
-              <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.GitHub.register_3') }} />
+              <li
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('security_settings.OAuth.GitHub.register_1', {
+                    link: '<a href="https://github.com/settings/developers" target=_blank>GitHub Developer Settings</a>',
+                  }),
+                }}
+              />
+              <li
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('security_settings.OAuth.GitHub.register_2', {
+                    url: gitHubCallbackUrl,
+                  }),
+                }}
+              />
+              <li
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('security_settings.OAuth.GitHub.register_3'),
+                }}
+              />
             </ol>
           </div>
         </div>
-
       </React.Fragment>
     </form>
   );
@@ -202,9 +306,9 @@ const GitHubSecurityManagementContents = (props: Props) => {
 /**
  * Wrapper component for using unstated
  */
-const GitHubSecurityManagementContentsWrapper = withUnstatedContainers(GitHubSecurityManagementContents, [
-  AdminGeneralSecurityContainer,
-  AdminGitHubSecurityContainer,
-]);
+const GitHubSecurityManagementContentsWrapper = withUnstatedContainers(
+  GitHubSecurityManagementContents,
+  [AdminGeneralSecurityContainer, AdminGitHubSecurityContainer],
+);
 
 export default GitHubSecurityManagementContentsWrapper;

+ 11 - 13
apps/app/src/client/components/Admin/Security/GoogleSecuritySetting.jsx

@@ -1,5 +1,4 @@
-import React, { useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect } from 'react';
 import PropTypes from 'prop-types';
 
 import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
@@ -7,37 +6,36 @@ import { toastError } from '~/client/util/toastr';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import GoogleSecurityManagementContents from './GoogleSecuritySettingContents';
 
 const GoogleSecurityManagement = (props) => {
   const { adminGoogleSecurityContainer } = props;
 
-  const fetchGoogleSecuritySettingsData = useCallback(async() => {
+  const fetchGoogleSecuritySettingsData = useCallback(async () => {
     try {
       await adminGoogleSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
     }
   }, [adminGoogleSecurityContainer]);
 
-
   useEffect(() => {
     fetchGoogleSecuritySettingsData();
-  }, [adminGoogleSecurityContainer, fetchGoogleSecuritySettingsData]);
+  }, [fetchGoogleSecuritySettingsData]);
 
   return <GoogleSecurityManagementContents />;
 };
 
-
 GoogleSecurityManagement.propTypes = {
-  adminGoogleSecurityContainer: PropTypes.instanceOf(AdminGoogleSecurityContainer).isRequired,
+  adminGoogleSecurityContainer: PropTypes.instanceOf(
+    AdminGoogleSecurityContainer,
+  ).isRequired,
 };
 
-const GoogleSecurityManagementWithUnstatedContainer = withUnstatedContainers(GoogleSecurityManagement, [
-  AdminGoogleSecurityContainer,
-]);
+const GoogleSecurityManagementWithUnstatedContainer = withUnstatedContainers(
+  GoogleSecurityManagement,
+  [AdminGoogleSecurityContainer],
+);
 
 export default GoogleSecurityManagementWithUnstatedContainer;

+ 175 - 60
apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx

@@ -1,6 +1,5 @@
 /* eslint-disable react/no-danger */
 import React, { useCallback, useEffect } from 'react';
-
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
@@ -8,27 +7,29 @@ import urljoin from 'url-join';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 type Props = {
-  adminGeneralSecurityContainer: AdminGeneralSecurityContainer
-  adminGoogleSecurityContainer: AdminGoogleSecurityContainer
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+  adminGoogleSecurityContainer: AdminGoogleSecurityContainer;
 };
 
 const GoogleSecurityManagementContents = (props: Props) => {
-  const {
-    adminGeneralSecurityContainer, adminGoogleSecurityContainer,
-  } = props;
+  const { adminGeneralSecurityContainer, adminGoogleSecurityContainer } = props;
 
   const { t } = useTranslation('admin');
   const siteUrl = useSiteUrlWithEmptyValueWarn();
 
   const { isGoogleEnabled } = adminGeneralSecurityContainer.state;
-  const { googleClientId, googleClientSecret, retrieveError } = adminGoogleSecurityContainer.state;
-  const googleCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/google/callback');
+  const { googleClientId, googleClientSecret, retrieveError } =
+    adminGoogleSecurityContainer.state;
+  const googleCallbackUrl = urljoin(
+    pathUtils.removeTrailingSlash(siteUrl),
+    '/passport/google/callback',
+  );
 
   const { register, handleSubmit, reset } = useForm();
 
@@ -40,32 +41,37 @@ const GoogleSecurityManagementContents = (props: Props) => {
     });
   }, [reset, googleClientId, googleClientSecret]);
 
-  const onClickSubmit = useCallback(async(data) => {
-    try {
-      await adminGoogleSecurityContainer.updateGoogleSetting({
-        googleClientId: data.googleClientId ?? '',
-        googleClientSecret: data.googleClientSecret ?? '',
-        isSameEmailTreatedAsIdenticalUser: adminGoogleSecurityContainer.state.isSameEmailTreatedAsIdenticalUser,
-      });
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_settings.OAuth.Google.updated_google'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminGoogleSecurityContainer, adminGeneralSecurityContainer, t]);
+  const onClickSubmit = useCallback(
+    async (data) => {
+      try {
+        await adminGoogleSecurityContainer.updateGoogleSetting({
+          googleClientId: data.googleClientId ?? '',
+          googleClientSecret: data.googleClientSecret ?? '',
+          isSameEmailTreatedAsIdenticalUser:
+            adminGoogleSecurityContainer.state
+              .isSameEmailTreatedAsIdenticalUser,
+        });
+        await adminGeneralSecurityContainer.retrieveSetupStratedies();
+        toastSuccess(t('security_settings.OAuth.Google.updated_google'));
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [adminGoogleSecurityContainer, adminGeneralSecurityContainer, t],
+  );
 
   return (
     <form onSubmit={handleSubmit(onClickSubmit)}>
       <React.Fragment>
-
         <h2 className="alert-anchor border-bottom">
           {t('security_settings.OAuth.Google.name')}
         </h2>
 
         {retrieveError != null && (
           <div className="alert alert-danger">
-            <p>{t('Error occurred')} : {retrieveError}</p>
+            <p>
+              {t('Error occurred')} : {retrieveError}
+            </p>
           </div>
         )}
 
@@ -76,48 +82,82 @@ const GoogleSecurityManagementContents = (props: Props) => {
                 id="isGoogleEnabled"
                 className="form-check-input"
                 type="checkbox"
-                checked={adminGeneralSecurityContainer.state.isGoogleEnabled || false}
-                onChange={() => { adminGeneralSecurityContainer.switchIsGoogleOAuthEnabled() }}
+                checked={
+                  adminGeneralSecurityContainer.state.isGoogleEnabled || false
+                }
+                onChange={() => {
+                  adminGeneralSecurityContainer.switchIsGoogleOAuthEnabled();
+                }}
               />
-              <label className="form-label form-check-label" htmlFor="isGoogleEnabled">
+              <label
+                className="form-label form-check-label"
+                htmlFor="isGoogleEnabled"
+              >
                 {t('security_settings.OAuth.Google.enable_google')}
               </label>
             </div>
-            {(!adminGeneralSecurityContainer.state.setupStrategies.includes('google') && isGoogleEnabled)
-              && <div className="badge text-bg-warning">{t('security_settings.setup_is_not_yet_complete')}</div>}
+            {!adminGeneralSecurityContainer.state.setupStrategies.includes(
+              'google',
+            ) &&
+              isGoogleEnabled && (
+                <div className="badge text-bg-warning">
+                  {t('security_settings.setup_is_not_yet_complete')}
+                </div>
+              )}
           </div>
         </div>
 
         <div className="row mb-5">
-          <label className="form-label col-12 col-md-3 text-start text-md-end py-2">{t('security_settings.callback_URL')}</label>
+          <label
+            className="form-label col-12 col-md-3 text-start text-md-end py-2"
+            htmlFor="googleCallbackUrl"
+          >
+            {t('security_settings.callback_URL')}
+          </label>
           <div className="col-12 col-md-6">
             <input
+              id="googleCallbackUrl"
               className="form-control"
               type="text"
               value={googleCallbackUrl}
               readOnly
             />
-            <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
+            <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' }) }}
+                  // eslint-disable-next-line max-len
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
 
-
         {isGoogleEnabled && (
           <React.Fragment>
-
-            <h3 className="border-bottom mb-4">{t('security_settings.configuration')}</h3>
+            <h3 className="border-bottom mb-4">
+              {t('security_settings.configuration')}
+            </h3>
 
             <div className="row mb-4">
-              <label htmlFor="googleClientId" className="col-3 text-end py-2 form-label">{t('security_settings.clientID')}</label>
+              <label
+                htmlFor="googleClientId"
+                className="col-3 text-end py-2 form-label"
+              >
+                {t('security_settings.clientID')}
+              </label>
               <div className="col-6">
                 <input
                   className="form-control"
@@ -125,13 +165,25 @@ const GoogleSecurityManagementContents = (props: Props) => {
                   {...register('googleClientId')}
                 />
                 <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_ID' }) }} />
+                  <small
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t('security_settings.Use env var if empty', {
+                        env: 'OAUTH_GOOGLE_CLIENT_ID',
+                      }),
+                    }}
+                  />
                 </p>
               </div>
             </div>
 
             <div className="row mb-4">
-              <label htmlFor="googleClientSecret" className="col-3 text-end py-2 form-label">{t('security_settings.client_secret')}</label>
+              <label
+                htmlFor="googleClientSecret"
+                className="col-3 text-end py-2 form-label"
+              >
+                {t('security_settings.client_secret')}
+              </label>
               <div className="col-6">
                 <input
                   className="form-control"
@@ -139,7 +191,14 @@ const GoogleSecurityManagementContents = (props: Props) => {
                   {...register('googleClientSecret')}
                 />
                 <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_GOOGLE_CLIENT_SECRET' }) }} />
+                  <small
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t('security_settings.Use env var if empty', {
+                        env: 'OAUTH_GOOGLE_CLIENT_SECRET',
+                      }),
+                    }}
+                  />
                 </p>
               </div>
             </div>
@@ -151,29 +210,52 @@ const GoogleSecurityManagementContents = (props: Props) => {
                     id="bindByUserNameGoogle"
                     className="form-check-input"
                     type="checkbox"
-                    checked={adminGoogleSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
-                    onChange={() => { adminGoogleSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                    checked={
+                      adminGoogleSecurityContainer.state
+                        .isSameEmailTreatedAsIdenticalUser || false
+                    }
+                    onChange={() => {
+                      adminGoogleSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser();
+                    }}
                   />
                   <label
                     className="form-check-label"
                     htmlFor="bindByUserNameGoogle"
-                    dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical') }}
-                  />
+                  >
+                    <span
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      dangerouslySetInnerHTML={{
+                        __html: t(
+                          'security_settings.Treat email matching as identical',
+                        ),
+                      }}
+                    />
+                  </label>
                 </div>
                 <p className="form-text text-muted">
-                  <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical_warn') }} />
+                  <small
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'security_settings.Treat email matching as identical_warn',
+                      ),
+                    }}
+                  />
                 </p>
               </div>
             </div>
 
             <div className="row mb-4">
               <div className="offset-3 col-5">
-                <button type="submit" className="btn btn-primary" disabled={retrieveError != null}>
+                <button
+                  type="submit"
+                  className="btn btn-primary"
+                  disabled={retrieveError != null}
+                >
                   {t('Update')}
                 </button>
               </div>
             </div>
-
           </React.Fragment>
         )}
 
@@ -181,29 +263,62 @@ const GoogleSecurityManagementContents = (props: Props) => {
 
         <div style={{ minHeight: '300px' }}>
           <h4>
-            <span className="material-symbols-outlined" aria-hidden="true">help</span>
-            <a href="#collapseHelpForGoogleOauth" data-bs-toggle="collapse"> {t('security_settings.OAuth.how_to.google')}</a>
+            <span className="material-symbols-outlined" aria-hidden="true">
+              help
+            </span>
+            <a href="#collapseHelpForGoogleOauth" data-bs-toggle="collapse">
+              {' '}
+              {t('security_settings.OAuth.how_to.google')}
+            </a>
           </h4>
           <div className="card custom-card bg-body-tertiary">
             <ol id="collapseHelpForGoogleOauth" className="collapse mb-0">
               {/* eslint-disable-next-line max-len */}
-              <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.Google.register_1', { link: '<a href="https://console.cloud.google.com/apis/credentials" target=_blank>Google Cloud Platform API Manager</a>' }) }} />
-              <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.Google.register_2') }} />
-              <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.Google.register_3') }} />
-              <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.Google.register_4', { url: googleCallbackUrl }) }} />
-              <li dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.Google.register_5') }} />
+              <li
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('security_settings.OAuth.Google.register_1', {
+                    link: '<a href="https://console.cloud.google.com/apis/credentials" target=_blank>Google Cloud Platform API Manager</a>',
+                  }),
+                }}
+              />
+              <li
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('security_settings.OAuth.Google.register_2'),
+                }}
+              />
+              <li
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('security_settings.OAuth.Google.register_3'),
+                }}
+              />
+              <li
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('security_settings.OAuth.Google.register_4', {
+                    url: googleCallbackUrl,
+                  }),
+                }}
+              />
+              <li
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t('security_settings.OAuth.Google.register_5'),
+                }}
+              />
             </ol>
           </div>
         </div>
-
       </React.Fragment>
     </form>
   );
 };
 
-const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContents, [
-  AdminGeneralSecurityContainer,
-  AdminGoogleSecurityContainer,
-]);
+const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(
+  GoogleSecurityManagementContents,
+  [AdminGeneralSecurityContainer, AdminGoogleSecurityContainer],
+);
 
 export default GoogleSecurityManagementContentsWrapper;

+ 51 - 31
apps/app/src/client/components/Admin/Security/LdapAuthTest.tsx

@@ -1,25 +1,22 @@
-import React, { useState, type JSX } from 'react';
-
+import React, { type JSX, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { apiPost } from '~/client/util/apiv1-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IResTestLdap } from '~/interfaces/ldap';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:security:AdminLdapSecurityContainer');
 
 type LdapAuthTestProps = {
-  username: string,
-  password: string,
-  onChangeUsername: (username: string) => void,
-  onChangePassword: (password: string) => void,
-}
+  username: string;
+  password: string;
+  onChangeUsername: (username: string) => void;
+  onChangePassword: (password: string) => void;
+};
 
 export const LdapAuthTest = (props: LdapAuthTestProps): JSX.Element => {
-  const {
-    username, password, onChangeUsername, onChangePassword,
-  } = props;
+  const { username, password, onChangeUsername, onChangePassword } = props;
   const { t } = useTranslation();
   const [logs, setLogs] = useState('');
   const [errorMessage, setErrorMessage] = useState('');
@@ -36,7 +33,7 @@ export const LdapAuthTest = (props: LdapAuthTestProps): JSX.Element => {
   /**
    * Test ldap auth
    */
-  const testLdapCredentials = async() => {
+  const testLdapCredentials = async () => {
     try {
       const response = await apiPost<IResTestLdap>('/login/testLdap', {
         loginForm: {
@@ -45,9 +42,8 @@ export const LdapAuthTest = (props: LdapAuthTestProps): JSX.Element => {
         },
       });
 
-      const {
-        err, message, status, ldapConfiguration, ldapAccountInfo,
-      } = response;
+      const { err, message, status, ldapConfiguration, ldapAccountInfo } =
+        response;
 
       // add logs
       if (err) {
@@ -68,62 +64,86 @@ export const LdapAuthTest = (props: LdapAuthTestProps): JSX.Element => {
       }
 
       if (ldapConfiguration) {
-        const prettified = JSON.stringify(ldapConfiguration.server, undefined, 4);
+        const prettified = JSON.stringify(
+          ldapConfiguration.server,
+          undefined,
+          4,
+        );
         addLogs(`LDAP Configuration : ${prettified}`);
       }
       if (ldapAccountInfo) {
         const prettified = JSON.stringify(ldapAccountInfo, undefined, 4);
         addLogs(`Retrieved LDAP Account : ${prettified}`);
       }
-
-    }
-    // Catch server communication error
-    catch (err) {
+    } catch (err) {
+      // Catch server communication error
       toastError(err);
       logger.error(err);
     }
   };
 
-
   return (
     <React.Fragment>
-      {successMessage !== '' && <div className="alert alert-success">{successMessage}</div>}
-      {errorMessage !== '' && <div className="alert alert-warning">{errorMessage}</div>}
+      {successMessage !== '' && (
+        <div className="alert alert-success">{successMessage}</div>
+      )}
+      {errorMessage !== '' && (
+        <div className="alert alert-warning">{errorMessage}</div>
+      )}
       <div className="row mt-3">
-        <label htmlFor="username" className="col-3 col-form-label text-end">{t('username')}</label>
+        <label htmlFor="username" className="col-3 col-form-label text-end">
+          {t('username')}
+        </label>
         <div className="col-6">
           <input
             className="form-control"
             name="username"
             value={username}
-            onChange={(e) => { onChangeUsername(e.target.value) }}
+            onChange={(e) => {
+              onChangeUsername(e.target.value);
+            }}
             autoComplete="off"
           />
         </div>
       </div>
       <div className="row mt-3">
-        <label htmlFor="password" className="col-3 col-form-label text-end">{t('Password')}</label>
+        <label htmlFor="password" className="col-3 col-form-label text-end">
+          {t('Password')}
+        </label>
         <div className="col-6">
           <input
             className="form-control"
             type="password"
             name="password"
             value={password}
-            onChange={(e) => { onChangePassword(e.target.value) }}
+            onChange={(e) => {
+              onChangePassword(e.target.value);
+            }}
             autoComplete="off"
           />
         </div>
       </div>
 
       <div className="mt-4">
-        <label className="form-label"><h5>Logs</h5></label>
-        <textarea id="taLogs" className="col form-control" rows={4} value={logs} readOnly />
+        <h5 className="form-label">Logs</h5>
+        <textarea
+          id="taLogs"
+          className="col form-control"
+          rows={4}
+          value={logs}
+          readOnly
+        />
       </div>
 
       <div className="mt-4">
-        <button type="button" className="btn btn-outline-secondary offset-5 col-2" onClick={testLdapCredentials}>Test</button>
+        <button
+          type="button"
+          className="btn btn-outline-secondary offset-5 col-2"
+          onClick={testLdapCredentials}
+        >
+          Test
+        </button>
       </div>
     </React.Fragment>
-
   );
 };

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

@@ -1,19 +1,11 @@
 import React from 'react';
-
 import PropTypes from 'prop-types';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import { LdapAuthTest } from './LdapAuthTest';
 
-
 class LdapAuthTestModal extends React.Component {
-
   constructor(props) {
     super(props);
 
@@ -41,7 +33,6 @@ class LdapAuthTestModal extends React.Component {
   }
 
   render() {
-
     return (
       <Modal isOpen={this.props.isOpen} toggle={this.props.onClose}>
         <ModalHeader tag="h4" toggle={this.props.onClose} className="text-info">
@@ -58,10 +49,8 @@ class LdapAuthTestModal extends React.Component {
       </Modal>
     );
   }
-
 }
 
-
 LdapAuthTestModal.propTypes = {
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,

+ 10 - 11
apps/app/src/client/components/Admin/Security/LdapSecuritySetting.jsx

@@ -1,5 +1,4 @@
-import React, { useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect } from 'react';
 import PropTypes from 'prop-types';
 
 import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
@@ -7,17 +6,15 @@ import { toastError } from '~/client/util/toastr';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import LdapSecuritySettingContents from './LdapSecuritySettingContents';
 
 const LdapSecuritySetting = (props) => {
   const { adminLdapSecurityContainer } = props;
 
-  const fetchLdapSecuritySettingsData = useCallback(async() => {
+  const fetchLdapSecuritySettingsData = useCallback(async () => {
     try {
       await adminLdapSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
     }
@@ -25,17 +22,19 @@ const LdapSecuritySetting = (props) => {
 
   useEffect(() => {
     fetchLdapSecuritySettingsData();
-  }, [adminLdapSecurityContainer, fetchLdapSecuritySettingsData]);
+  }, [fetchLdapSecuritySettingsData]);
 
   return <LdapSecuritySettingContents />;
 };
 
 LdapSecuritySetting.propTypes = {
-  adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer).isRequired,
+  adminLdapSecurityContainer: PropTypes.instanceOf(AdminLdapSecurityContainer)
+    .isRequired,
 };
 
-const LdapSecuritySettingWithUnstatedContainer = withUnstatedContainers(LdapSecuritySetting, [
-  AdminLdapSecurityContainer,
-]);
+const LdapSecuritySettingWithUnstatedContainer = withUnstatedContainers(
+  LdapSecuritySetting,
+  [AdminLdapSecurityContainer],
+);
 
 export default LdapSecuritySettingWithUnstatedContainer;

+ 323 - 136
apps/app/src/client/components/Admin/Security/LdapSecuritySettingContents.tsx

@@ -1,19 +1,14 @@
-import React, {
-  useState, useEffect, useCallback,
-} from 'react';
-
+import React, { useCallback, useEffect, useState } 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 { toastError, toastSuccess } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import LdapAuthTestModal from './LdapAuthTestModal';
 
-
 type Props = {
   adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
   adminLdapSecurityContainer: AdminLdapSecurityContainer;
@@ -26,12 +21,20 @@ const LdapSecuritySettingContents = (props: Props) => {
 
   const { isLdapEnabled } = adminGeneralSecurityContainer.state;
   const {
-    serverUrl, ldapBindDN, ldapBindDNPassword, ldapSearchFilter,
-    ldapAttrMapUsername, ldapAttrMapMail, ldapAttrMapName,
-    ldapGroupSearchBase, ldapGroupSearchFilter, ldapGroupDnProperty,
+    serverUrl,
+    ldapBindDN,
+    ldapBindDNPassword,
+    ldapSearchFilter,
+    ldapAttrMapUsername,
+    ldapAttrMapMail,
+    ldapAttrMapName,
+    ldapGroupSearchBase,
+    ldapGroupSearchFilter,
+    ldapGroupDnProperty,
   } = adminLdapSecurityContainer.state;
 
-  const [isLdapAuthTestModalShown, setIsLdapAuthTestModalShown] = useState(false);
+  const [isLdapAuthTestModalShown, setIsLdapAuthTestModalShown] =
+    useState(false);
 
   const { register, handleSubmit, reset } = useForm();
 
@@ -49,34 +52,46 @@ const LdapSecuritySettingContents = (props: Props) => {
       ldapGroupDnProperty,
     });
   }, [
-    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.updateLdapSetting({
-        serverUrl: data.serverUrl,
-        isUserBind: adminLdapSecurityContainer.state.isUserBind,
-        ldapBindDN: data.ldapBindDN,
-        ldapBindDNPassword: data.ldapBindDNPassword,
-        ldapSearchFilter: data.ldapSearchFilter,
-        ldapAttrMapUsername: data.ldapAttrMapUsername,
-        isSameUsernameTreatedAsIdenticalUser: adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser,
-        ldapAttrMapMail: data.ldapAttrMapMail,
-        ldapAttrMapName: data.ldapAttrMapName,
-        ldapGroupSearchBase: data.ldapGroupSearchBase,
-        ldapGroupSearchFilter: data.ldapGroupSearchFilter,
-        ldapGroupDnProperty: data.ldapGroupDnProperty,
-      });
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_settings.ldap.updated_ldap'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, adminLdapSecurityContainer, adminGeneralSecurityContainer]);
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        await adminLdapSecurityContainer.updateLdapSetting({
+          serverUrl: data.serverUrl,
+          isUserBind: adminLdapSecurityContainer.state.isUserBind,
+          ldapBindDN: data.ldapBindDN,
+          ldapBindDNPassword: data.ldapBindDNPassword,
+          ldapSearchFilter: data.ldapSearchFilter,
+          ldapAttrMapUsername: data.ldapAttrMapUsername,
+          isSameUsernameTreatedAsIdenticalUser:
+            adminLdapSecurityContainer.state
+              .isSameUsernameTreatedAsIdenticalUser,
+          ldapAttrMapMail: data.ldapAttrMapMail,
+          ldapAttrMapName: data.ldapAttrMapName,
+          ldapGroupSearchBase: data.ldapGroupSearchBase,
+          ldapGroupSearchFilter: data.ldapGroupSearchFilter,
+          ldapGroupDnProperty: data.ldapGroupDnProperty,
+        });
+        await adminGeneralSecurityContainer.retrieveSetupStratedies();
+        toastSuccess(t('security_settings.ldap.updated_ldap'));
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, adminLdapSecurityContainer, adminGeneralSecurityContainer],
+  );
 
   const openLdapAuthTestModal = useCallback(() => {
     setIsLdapAuthTestModalShown(true);
@@ -88,10 +103,7 @@ const LdapSecuritySettingContents = (props: Props) => {
 
   return (
     <React.Fragment>
-
-      <h2 className="alert-anchor border-bottom mb-4">
-        LDAP
-      </h2>
+      <h2 className="alert-anchor border-bottom mb-4">LDAP</h2>
 
       <div className="row my-4">
         <div className="col-6 offset-3">
@@ -101,25 +113,39 @@ const LdapSecuritySettingContents = (props: Props) => {
               className="form-check-input"
               type="checkbox"
               checked={isLdapEnabled}
-              onChange={() => { adminGeneralSecurityContainer.switchIsLdapEnabled() }}
+              onChange={() => {
+                adminGeneralSecurityContainer.switchIsLdapEnabled();
+              }}
             />
-            <label className="form-label form-check-label" htmlFor="isLdapEnabled">
+            <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>}
+          {!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>
+          <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">
+            <label
+              htmlFor="serverUrl"
+              className="text-start text-md-end col-md-3 col-form-label"
+            >
               Server URL
             </label>
             <div className="col-md-9">
@@ -132,17 +158,23 @@ const LdapSecuritySettingContents = (props: Props) => {
                 <p
                   className="form-text text-muted"
                   // eslint-disable-next-line react/no-danger
-                  dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.server_url_detail') }}
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
+                {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">
+            <span className="form-label text-start text-md-end col-md-3 col-form-label">
               <strong>{t('security_settings.ldap.bind_mode')}</strong>
-            </label>
+            </span>
             <div className="col-md-9">
               <div className="dropdown">
                 <button
@@ -153,15 +185,33 @@ const LdapSecuritySettingContents = (props: Props) => {
                   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>}
+                  {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) }}>
+                <div className="dropdown-menu">
+                  <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) }}>
+                  <button
+                    className="dropdown-item"
+                    type="button"
+                    onClick={() => {
+                      adminLdapSecurityContainer.changeLdapBindMode(false);
+                    }}
+                  >
                     {t('security_settings.ldap.bind_manager')}
                   </button>
                 </div>
@@ -170,89 +220,135 @@ const LdapSecuritySettingContents = (props: Props) => {
           </div>
 
           <div className="row my-3">
-            <label className="form-label text-start text-md-end col-md-3 col-form-label">
+            <label
+              className="form-label text-start text-md-end col-md-3 col-form-label"
+              htmlFor="ldapBindDN"
+            >
               <strong>Bind DN</strong>
             </label>
             <div className="col-md-9">
               <input
+                id="ldapBindDN"
                 className="form-control"
                 type="text"
                 {...register('ldapBindDN')}
               />
-              {(adminLdapSecurityContainer.state.isUserBind === true) ? (
+              {adminLdapSecurityContainer.state.isUserBind === true ? (
                 <p className="form-text text-muted">
                   <small>
-                    {t('security_settings.ldap.bind_DN_user_detail1')}<br />
+                    {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>
+                    <span
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      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>
-                )}
+              ) : (
+                <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">
+            <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) ? (
+              {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>
-                  </>
-                )}
+              ) : (
+                <>
+                  <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">
+            <label
+              className="form-label text-start text-md-end col-md-3 col-form-label"
+              htmlFor="ldapSearchFilter"
+            >
               <strong>{t('security_settings.ldap.search_filter')}</strong>
             </label>
             <div className="col-md-9">
               <input
+                id="ldapSearchFilter"
                 className="form-control"
                 type="text"
                 {...register('ldapSearchFilter')}
               />
               <p className="form-text text-muted">
                 <small>
-                  {t('security_settings.ldap.search_filter_detail1')}<br />
+                  {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 />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    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') }} />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    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')}:
+                  {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>
@@ -264,7 +360,10 @@ const LdapSecuritySettingContents = (props: Props) => {
           </h3>
 
           <div className="row my-3">
-            <label className="form-label text-start text-md-end col-md-3 col-form-label" htmlFor="attrMapUsername">
+            <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">
@@ -276,7 +375,12 @@ const LdapSecuritySettingContents = (props: Props) => {
               />
               <p className="form-text text-muted">
                 {/* eslint-disable-next-line react/no-danger */}
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.username_detail') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t('security_settings.ldap.username_detail'),
+                  }}
+                />
               </p>
             </div>
           </div>
@@ -288,25 +392,48 @@ const LdapSecuritySettingContents = (props: Props) => {
                   type="checkbox"
                   className="form-check-input"
                   id="isSameUsernameTreatedAsIdenticalUser"
-                  checked={adminLdapSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
-                  onChange={() => { adminLdapSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  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') }}
-                />
+                >
+                  <span
+                    // eslint-disable-next-line react/no-danger
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'security_settings.Treat username matching as identical',
+                      ),
+                    }}
+                  />
+                </label>
               </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') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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">
+            <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">
@@ -317,15 +444,16 @@ const LdapSecuritySettingContents = (props: Props) => {
                 {...register('ldapAttrMapMail')}
               />
               <p className="form-text text-muted">
-                <small>
-                  {t('security_settings.ldap.mail_detail')}
-                </small>
+                <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">
+            <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">
@@ -335,21 +463,23 @@ const LdapSecuritySettingContents = (props: Props) => {
                 {...register('ldapAttrMapName')}
               />
               <p className="form-text text-muted">
-                <small>
-                  {t('security_settings.ldap.name_detail')}
-                </small>
+                <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
+              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
@@ -360,15 +490,27 @@ const LdapSecuritySettingContents = (props: Props) => {
               <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>
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    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">
+            <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">
@@ -380,9 +522,32 @@ const LdapSecuritySettingContents = (props: Props) => {
               <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') }} />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'security_settings.ldap.group_search_filter_detail1',
+                      ),
+                    }}
+                  />
+                  <br />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'security_settings.ldap.group_search_filter_detail2',
+                      ),
+                    }}
+                  />
+                  <br />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'security_settings.ldap.group_search_filter_detail3',
+                      ),
+                    }}
+                  />
                   {/* eslint-enable react/no-danger */}
                 </small>
               </p>
@@ -390,15 +555,27 @@ const LdapSecuritySettingContents = (props: Props) => {
                 <small>
                   {t('security_settings.example')}:
                   {/* eslint-disable-next-line react/no-danger */}
-                  <span dangerouslySetInnerHTML={{ __html: t('security_settings.ldap.group_search_filter_detail4') }} />
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    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
+              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
@@ -409,7 +586,14 @@ const LdapSecuritySettingContents = (props: Props) => {
               />
               <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') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t(
+                      'security_settings.ldap.group_search_user_DN_property_detail',
+                    ),
+                  }}
+                />
               </p>
             </div>
           </div>
@@ -418,7 +602,9 @@ const LdapSecuritySettingContents = (props: Props) => {
               <button
                 type="submit"
                 className="btn btn-primary"
-                disabled={adminLdapSecurityContainer.state.retrieveError != null}
+                disabled={
+                  adminLdapSecurityContainer.state.retrieveError != null
+                }
               >
                 {t('Update')}
               </button>
@@ -426,24 +612,25 @@ const LdapSecuritySettingContents = (props: Props) => {
                 type="button"
                 className="btn btn-outline-secondary ms-2"
                 onClick={openLdapAuthTestModal}
-              >{t('security_settings.ldap.test_config')}
+              >
+                {t('security_settings.ldap.test_config')}
               </button>
             </div>
           </div>
-
         </form>
       )}
 
-
-      <LdapAuthTestModal isOpen={isLdapAuthTestModalShown} onClose={closeLdapAuthTestModal} />
-
+      <LdapAuthTestModal
+        isOpen={isLdapAuthTestModalShown}
+        onClose={closeLdapAuthTestModal}
+      />
     </React.Fragment>
   );
 };
 
-const LdapSecuritySettingContentsWrapper = withUnstatedContainers(LdapSecuritySettingContents, [
-  AdminGeneralSecurityContainer,
-  AdminLdapSecurityContainer,
-]);
+const LdapSecuritySettingContentsWrapper = withUnstatedContainers(
+  LdapSecuritySettingContents,
+  [AdminGeneralSecurityContainer, AdminLdapSecurityContainer],
+);
 
 export default LdapSecuritySettingContentsWrapper;

+ 10 - 12
apps/app/src/client/components/Admin/Security/LocalSecuritySetting.jsx

@@ -1,5 +1,4 @@
-import React, { useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect } from 'react';
 import PropTypes from 'prop-types';
 
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
@@ -7,36 +6,35 @@ import { toastError } from '~/client/util/toastr';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import LocalSecuritySettingContents from './LocalSecuritySettingContents';
 
 const LocalSecuritySetting = (props) => {
   const { adminLocalSecurityContainer } = props;
 
-  const fetchLocalSecuritySettingsData = useCallback(async() => {
+  const fetchLocalSecuritySettingsData = useCallback(async () => {
     try {
       await adminLocalSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
     }
   }, [adminLocalSecurityContainer]);
 
-
   useEffect(() => {
     fetchLocalSecuritySettingsData();
-  }, [adminLocalSecurityContainer, fetchLocalSecuritySettingsData]);
+  }, [fetchLocalSecuritySettingsData]);
 
   return <LocalSecuritySettingContents />;
 };
 
 LocalSecuritySetting.propTypes = {
-  adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer).isRequired,
+  adminLocalSecurityContainer: PropTypes.instanceOf(AdminLocalSecurityContainer)
+    .isRequired,
 };
 
-const LocalSecuritySettingWithUnstatedContainer = withUnstatedContainers(LocalSecuritySetting, [
-  AdminLocalSecurityContainer,
-]);
+const LocalSecuritySettingWithUnstatedContainer = withUnstatedContainers(
+  LocalSecuritySetting,
+  [AdminLocalSecurityContainer],
+);
 
 export default LocalSecuritySettingWithUnstatedContainer;

+ 117 - 55
apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx

@@ -1,12 +1,12 @@
 import React, { useCallback, useEffect } from 'react';
 import Link from 'next/link';
-import { useTranslation } from 'next-i18next';
 import { useAtomValue } from 'jotai';
+import { useTranslation } from 'next-i18next';
 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 { toastError, toastSuccess } from '~/client/util/toastr';
 import { isMailerSetupAtom } from '~/states/server-configurations';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -17,58 +17,70 @@ type Props = {
 };
 
 const LocalSecuritySettingContents = (props: Props): JSX.Element => {
-  const {
-    adminGeneralSecurityContainer,
-    adminLocalSecurityContainer,
-  } = props;
+  const { adminGeneralSecurityContainer, adminLocalSecurityContainer } = props;
 
   const { t } = useTranslation('admin');
   const isMailerSetup = useAtomValue(isMailerSetupAtom);
 
   const { register, handleSubmit, reset } = useForm();
 
-  const { registrationMode, isPasswordResetEnabled, isEmailAuthenticationEnabled } = adminLocalSecurityContainer.state;
+  const {
+    registrationMode,
+    isPasswordResetEnabled,
+    isEmailAuthenticationEnabled,
+  } = adminLocalSecurityContainer.state;
   const { isLocalEnabled } = adminGeneralSecurityContainer.state;
 
   useEffect(() => {
     reset({
-      registrationWhitelist: adminLocalSecurityContainer.state.registrationWhitelist.join('\n'),
+      registrationWhitelist:
+        adminLocalSecurityContainer.state.registrationWhitelist.join('\n'),
     });
   }, [reset, adminLocalSecurityContainer.state.registrationWhitelist]);
 
-  const onSubmit = useCallback(async(data) => {
-    try {
-      await adminLocalSecurityContainer.updateLocalSecuritySetting({
-        registrationMode: adminLocalSecurityContainer.state.registrationMode,
-        registrationWhitelist: data.registrationWhitelist.split('\n'),
-        isPasswordResetEnabled: adminLocalSecurityContainer.state.isPasswordResetEnabled,
-        isEmailAuthenticationEnabled: adminLocalSecurityContainer.state.isEmailAuthenticationEnabled,
-      });
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_settings.updated_general_security_setting'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, adminGeneralSecurityContainer, adminLocalSecurityContainer]);
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        await adminLocalSecurityContainer.updateLocalSecuritySetting({
+          registrationMode: adminLocalSecurityContainer.state.registrationMode,
+          registrationWhitelist: data.registrationWhitelist.split('\n'),
+          isPasswordResetEnabled:
+            adminLocalSecurityContainer.state.isPasswordResetEnabled,
+          isEmailAuthenticationEnabled:
+            adminLocalSecurityContainer.state.isEmailAuthenticationEnabled,
+        });
+        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}
+            {t('Error occurred')} :{' '}
+            {adminLocalSecurityContainer.state.retrieveError}
           </p>
         </div>
       )}
-      <h2 className="alert-anchor border-bottom">{t('security_settings.Local.name')}</h2>
+      <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
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
           dangerouslySetInnerHTML={{
-            __html: t('security_settings.Local.note for the only env option', { env: 'LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS' }),
+            __html: t('security_settings.Local.note for the only env option', {
+              env: 'LOCAL_STRATEGY_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS',
+            }),
           }}
         />
       )}
@@ -81,22 +93,34 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
               className="form-check-input"
               id="isLocalEnabled"
               checked={isLocalEnabled}
-              onChange={() => adminGeneralSecurityContainer.switchIsLocalEnabled()}
+              onChange={() =>
+                adminGeneralSecurityContainer.switchIsLocalEnabled()
+              }
               disabled={adminLocalSecurityContainer.state.useOnlyEnvVars}
             />
-            <label className="form-label form-check-label" htmlFor="isLocalEnabled">
+            <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>
-          )}
+          {!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>
+          <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">
@@ -112,16 +136,21 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
                   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')}
+                  {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">
+                <div className="dropdown-menu">
                   <button
                     className="dropdown-item"
                     type="button"
                     onClick={() => {
-                      adminLocalSecurityContainer.changeRegistrationMode('Open');
+                      adminLocalSecurityContainer.changeRegistrationMode(
+                        'Open',
+                      );
                     }}
                   >
                     {t('security_settings.registration_mode.open')}
@@ -130,7 +159,9 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
                     className="dropdown-item"
                     type="button"
                     onClick={() => {
-                      adminLocalSecurityContainer.changeRegistrationMode('Restricted');
+                      adminLocalSecurityContainer.changeRegistrationMode(
+                        'Restricted',
+                      );
                     }}
                   >
                     {t('security_settings.registration_mode.restricted')}
@@ -139,19 +170,30 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
                     className="dropdown-item"
                     type="button"
                     onClick={() => {
-                      adminLocalSecurityContainer.changeRegistrationMode('Closed');
+                      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>
+              <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') }} />
+              <strong
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t(
+                    'security_settings.The whitelist of registration permission E-mail address',
+                  ),
+                }}
+              />
             </div>
             <div className="col-12 col-md-8">
               <textarea
@@ -171,7 +213,9 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
           </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>
+            <span className="col-12 col-md-4 text-start text-md-end col-form-label">
+              {t('security_settings.Local.password_reset_by_users')}
+            </span>
             <div className="col-12 col-md-8">
               <div className="form-check form-switch form-check-success">
                 <input
@@ -179,17 +223,25 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
                   className="form-check-input"
                   id="isPasswordResetEnabled"
                   checked={isPasswordResetEnabled}
-                  onChange={() => adminLocalSecurityContainer.switchIsPasswordResetEnabled()}
+                  onChange={() =>
+                    adminLocalSecurityContainer.switchIsPasswordResetEnabled()
+                  }
                 />
-                <label className="form-label form-check-label" htmlFor="isPasswordResetEnabled">
+                <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>
+                  <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')}
+                    <span className="material-symbols-outlined">link</span>{' '}
+                    {t('app_setting.mail_settings')}
                   </Link>
                 </div>
               )}
@@ -200,7 +252,9 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
           </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>
+            <span className="col-12 col-md-4 text-start text-md-end col-form-label">
+              {t('security_settings.Local.email_authentication')}
+            </span>
             <div className="col-12 col-md-8">
               <div className="form-check form-switch form-check-success">
                 <input
@@ -208,9 +262,14 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
                   className="form-check-input"
                   id="isEmailAuthenticationEnabled"
                   checked={isEmailAuthenticationEnabled}
-                  onChange={() => adminLocalSecurityContainer.switchIsEmailAuthenticationEnabled()}
+                  onChange={() =>
+                    adminLocalSecurityContainer.switchIsEmailAuthenticationEnabled()
+                  }
                 />
-                <label className="form-label form-check-label" htmlFor="isEmailAuthenticationEnabled">
+                <label
+                  className="form-label form-check-label"
+                  htmlFor="isEmailAuthenticationEnabled"
+                >
                   {t('security_settings.Local.enable_email_authentication')}
                 </label>
               </div>
@@ -218,7 +277,8 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
                 <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')}
+                    <span className="material-symbols-outlined">link</span>{' '}
+                    {t('app_setting.mail_settings')}
                   </Link>
                 </div>
               )}
@@ -233,7 +293,9 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
               <button
                 type="submit"
                 className="btn btn-primary"
-                disabled={adminLocalSecurityContainer.state.retrieveError != null}
+                disabled={
+                  adminLocalSecurityContainer.state.retrieveError != null
+                }
               >
                 {t('Update')}
               </button>
@@ -245,9 +307,9 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
   );
 };
 
-const LocalSecuritySettingContentsWrapper = withUnstatedContainers(LocalSecuritySettingContents, [
-  AdminGeneralSecurityContainer,
-  AdminLocalSecurityContainer,
-]);
+const LocalSecuritySettingContentsWrapper = withUnstatedContainers(
+  LocalSecuritySettingContents,
+  [AdminGeneralSecurityContainer, AdminLocalSecurityContainer],
+);
 
 export default LocalSecuritySettingContentsWrapper;

+ 10 - 11
apps/app/src/client/components/Admin/Security/OidcSecuritySetting.jsx

@@ -1,5 +1,4 @@
-import React, { useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect } from 'react';
 import PropTypes from 'prop-types';
 
 import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
@@ -7,17 +6,15 @@ import { toastError } from '~/client/util/toastr';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import OidcSecurityManagementContents from './OidcSecuritySettingContents';
 
 const OidcSecurityManagement = (props) => {
   const { adminOidcSecurityContainer } = props;
 
-  const fetchOidcSecuritySettingsData = useCallback(async() => {
+  const fetchOidcSecuritySettingsData = useCallback(async () => {
     try {
       await adminOidcSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
     }
@@ -25,17 +22,19 @@ const OidcSecurityManagement = (props) => {
 
   useEffect(() => {
     fetchOidcSecuritySettingsData();
-  }, [adminOidcSecurityContainer, fetchOidcSecuritySettingsData]);
+  }, [fetchOidcSecuritySettingsData]);
 
   return <OidcSecurityManagementContents />;
 };
 
 OidcSecurityManagement.propTypes = {
-  adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer).isRequired,
+  adminOidcSecurityContainer: PropTypes.instanceOf(AdminOidcSecurityContainer)
+    .isRequired,
 };
 
-const OidcSecurityManagementWithUnstatedContainer = withUnstatedContainers(OidcSecurityManagement, [
-  AdminOidcSecurityContainer,
-]);
+const OidcSecurityManagementWithUnstatedContainer = withUnstatedContainers(
+  OidcSecurityManagement,
+  [AdminOidcSecurityContainer],
+);
 
 export default OidcSecurityManagementWithUnstatedContainer;

+ 398 - 108
apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx

@@ -1,14 +1,12 @@
-import React, { useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect } 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 { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -22,18 +20,31 @@ const OidcSecurityManagementContents = (props: Props) => {
   const { t } = useTranslation('admin');
   const siteUrl = useSiteUrlWithEmptyValueWarn();
 
-  const {
-    adminGeneralSecurityContainer, adminOidcSecurityContainer,
-  } = props;
+  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,
+    oidcProviderName,
+    oidcIssuerHost,
+    oidcClientId,
+    oidcClientSecret,
+    oidcAuthorizationEndpoint,
+    oidcTokenEndpoint,
+    oidcRevocationEndpoint,
+    oidcIntrospectionEndpoint,
+    oidcUserInfoEndpoint,
+    oidcEndSessionEndpoint,
+    oidcRegistrationEndpoint,
+    oidcJWKSUri,
+    oidcAttrMapId,
+    oidcAttrMapUserName,
+    oidcAttrMapName,
+    oidcAttrMapEmail,
   } = adminOidcSecurityContainer.state;
 
-  const oidcCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/oidc/callback');
+  const oidcCallbackUrl = urljoin(
+    pathUtils.removeTrailingSlash(siteUrl),
+    '/passport/oidc/callback',
+  );
 
   const { register, handleSubmit, reset } = useForm();
 
@@ -57,41 +68,59 @@ const OidcSecurityManagementContents = (props: Props) => {
       oidcAttrMapEmail,
     });
   }, [
-    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.updateOidcSetting({
-        oidcProviderName: data.oidcProviderName,
-        oidcIssuerHost: data.oidcIssuerHost,
-        oidcClientId: data.oidcClientId,
-        oidcClientSecret: data.oidcClientSecret,
-        oidcAuthorizationEndpoint: data.oidcAuthorizationEndpoint,
-        oidcTokenEndpoint: data.oidcTokenEndpoint,
-        oidcRevocationEndpoint: data.oidcRevocationEndpoint,
-        oidcIntrospectionEndpoint: data.oidcIntrospectionEndpoint,
-        oidcUserInfoEndpoint: data.oidcUserInfoEndpoint,
-        oidcEndSessionEndpoint: data.oidcEndSessionEndpoint,
-        oidcRegistrationEndpoint: data.oidcRegistrationEndpoint,
-        oidcJWKSUri: data.oidcJWKSUri,
-        oidcAttrMapId: data.oidcAttrMapId,
-        oidcAttrMapUserName: data.oidcAttrMapUserName,
-        oidcAttrMapName: data.oidcAttrMapName,
-        oidcAttrMapEmail: data.oidcAttrMapEmail,
-        isSameUsernameTreatedAsIdenticalUser: adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser,
-        isSameEmailTreatedAsIdenticalUser: adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser,
-      });
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-      toastSuccess(t('security_settings.OAuth.OIDC.updated_oidc'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, adminOidcSecurityContainer, adminGeneralSecurityContainer]);
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        await adminOidcSecurityContainer.updateOidcSetting({
+          oidcProviderName: data.oidcProviderName,
+          oidcIssuerHost: data.oidcIssuerHost,
+          oidcClientId: data.oidcClientId,
+          oidcClientSecret: data.oidcClientSecret,
+          oidcAuthorizationEndpoint: data.oidcAuthorizationEndpoint,
+          oidcTokenEndpoint: data.oidcTokenEndpoint,
+          oidcRevocationEndpoint: data.oidcRevocationEndpoint,
+          oidcIntrospectionEndpoint: data.oidcIntrospectionEndpoint,
+          oidcUserInfoEndpoint: data.oidcUserInfoEndpoint,
+          oidcEndSessionEndpoint: data.oidcEndSessionEndpoint,
+          oidcRegistrationEndpoint: data.oidcRegistrationEndpoint,
+          oidcJWKSUri: data.oidcJWKSUri,
+          oidcAttrMapId: data.oidcAttrMapId,
+          oidcAttrMapUserName: data.oidcAttrMapUserName,
+          oidcAttrMapName: data.oidcAttrMapName,
+          oidcAttrMapEmail: data.oidcAttrMapEmail,
+          isSameUsernameTreatedAsIdenticalUser:
+            adminOidcSecurityContainer.state
+              .isSameUsernameTreatedAsIdenticalUser,
+          isSameEmailTreatedAsIdenticalUser:
+            adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser,
+        });
+        await adminGeneralSecurityContainer.retrieveSetupStratedies();
+        toastSuccess(t('security_settings.OAuth.OIDC.updated_oidc'));
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, adminOidcSecurityContainer, adminGeneralSecurityContainer],
+  );
 
   return (
     <>
@@ -107,33 +136,58 @@ const OidcSecurityManagementContents = (props: Props) => {
               className="form-check-input"
               type="checkbox"
               checked={adminGeneralSecurityContainer.state.isOidcEnabled}
-              onChange={() => { adminGeneralSecurityContainer.switchIsOidcEnabled() }}
+              onChange={() => {
+                adminGeneralSecurityContainer.switchIsOidcEnabled();
+              }}
             />
-            <label className="form-label form-check-label" htmlFor="isOidcEnabled">
+            <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>}
+          {!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>
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="oidcCallbackUrl"
+        >
+          {t('security_settings.callback_URL')}
+        </label>
         <div className="col-md-6">
           <input
+            id="oidcCallbackUrl"
             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>
+          <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' }) }}
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                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>
           )}
@@ -142,11 +196,17 @@ const OidcSecurityManagementContents = (props: Props) => {
 
       {isOidcEnabled && (
         <form onSubmit={handleSubmit(onSubmit)}>
-
-          <h3 className="border-bottom mb-4">{t('security_settings.configuration')}</h3>
+          <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>
+            <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"
@@ -157,7 +217,12 @@ const OidcSecurityManagementContents = (props: Props) => {
           </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>
+            <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"
@@ -165,13 +230,25 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcIssuerHost')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_OIDC_ISSUER_HOST' }) }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
+            <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"
@@ -179,13 +256,25 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcClientId')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_ID' }) }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
+            <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"
@@ -193,13 +282,23 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcClientSecret')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Use env var if empty', { env: 'OAUTH_OIDC_CLIENT_SECRET' }) }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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">
+            <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">
@@ -209,13 +308,25 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcAuthorizationEndpoint')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
+            <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"
@@ -223,13 +334,23 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcTokenEndpoint')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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">
+            <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">
@@ -239,13 +360,23 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcRevocationEndpoint')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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">
+            <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">
@@ -255,13 +386,23 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcIntrospectionEndpoint')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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">
+            <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">
@@ -271,13 +412,23 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcUserInfoEndpoint')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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">
+            <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">
@@ -287,13 +438,23 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcEndSessionEndpoint')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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">
+            <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">
@@ -303,13 +464,25 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcRegistrationEndpoint')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
+            <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"
@@ -317,7 +490,14 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcJWKSUri')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.Use discovered URL if empty') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t(
+                      'security_settings.OAuth.OIDC.Use discovered URL if empty',
+                    ),
+                  }}
+                />
               </p>
             </div>
           </div>
@@ -327,7 +507,12 @@ const OidcSecurityManagementContents = (props: Props) => {
           </h3>
 
           <div className="row mb-4">
-            <label htmlFor="oidcAttrMapId" className="text-start text-md-end col-md-3 col-form-label">Identifier</label>
+            <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"
@@ -335,13 +520,23 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcAttrMapId')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.id_detail') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
+            <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"
@@ -349,13 +544,23 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcAttrMapUserName')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.username_detail') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
+            <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"
@@ -363,13 +568,23 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcAttrMapName')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.name_detail') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
+            <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"
@@ -377,27 +592,50 @@ const OidcSecurityManagementContents = (props: Props) => {
                 {...register('oidcAttrMapEmail')}
               />
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.OAuth.OIDC.mapping_detail', { target: t('Email') }) }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  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>
+            <label
+              className="form-label text-start text-md-end col-md-3 col-form-label"
+              htmlFor="oidcCallbackUrlPreview"
+            >
+              {t('security_settings.callback_URL')}
+            </label>
             <div className="col-md-6">
               <input
+                id="oidcCallbackUrlPreview"
                 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>
+              <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' }) }}
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    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>
               )}
@@ -411,17 +649,37 @@ const OidcSecurityManagementContents = (props: Props) => {
                   id="bindByUserName-oidc"
                   className="form-check-input"
                   type="checkbox"
-                  checked={adminOidcSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser}
-                  onChange={() => { adminOidcSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                  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') }}
-                />
+                >
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'security_settings.Treat username matching as identical',
+                      ),
+                    }}
+                  />
+                </label>
               </div>
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical_warn') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t(
+                      'security_settings.Treat username matching as identical_warn',
+                    ),
+                  }}
+                />
               </p>
             </div>
           </div>
@@ -433,17 +691,37 @@ const OidcSecurityManagementContents = (props: Props) => {
                   id="bindByEmail-oidc"
                   className="form-check-input"
                   type="checkbox"
-                  checked={adminOidcSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
-                  onChange={() => { adminOidcSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                  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') }}
-                />
+                >
+                  <span
+                    // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                    dangerouslySetInnerHTML={{
+                      __html: t(
+                        'security_settings.Treat email matching as identical',
+                      ),
+                    }}
+                  />
+                </label>
               </div>
               <p className="form-text text-muted">
-                <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical_warn') }} />
+                <small
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t(
+                      'security_settings.Treat email matching as identical_warn',
+                    ),
+                  }}
+                />
               </p>
             </div>
           </div>
@@ -453,7 +731,9 @@ const OidcSecurityManagementContents = (props: Props) => {
               <button
                 type="submit"
                 className="btn btn-primary"
-                disabled={adminOidcSecurityContainer.state.retrieveError != null}
+                disabled={
+                  adminOidcSecurityContainer.state.retrieveError != null
+                }
               >
                 {t('Update')}
               </button>
@@ -462,30 +742,40 @@ const OidcSecurityManagementContents = (props: Props) => {
         </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>
+          <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
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+              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,
-]);
+const OidcSecurityManagementContentsWrapper = withUnstatedContainers(
+  OidcSecurityManagementContents,
+  [AdminGeneralSecurityContainer, AdminOidcSecurityContainer],
+);
 
 export default OidcSecurityManagementContentsWrapper;

+ 10 - 11
apps/app/src/client/components/Admin/Security/SamlSecuritySetting.jsx

@@ -1,5 +1,4 @@
-import React, { useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect } from 'react';
 import PropTypes from 'prop-types';
 
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
@@ -7,17 +6,15 @@ import { toastError } from '~/client/util/toastr';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import SamlSecuritySettingContents from './SamlSecuritySettingContents';
 
 const SamlSecurityManagement = (props) => {
   const { adminSamlSecurityContainer } = props;
 
-  const fetchSamlSecuritySettingsData = useCallback(async() => {
+  const fetchSamlSecuritySettingsData = useCallback(async () => {
     try {
       await adminSamlSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
     }
@@ -25,17 +22,19 @@ const SamlSecurityManagement = (props) => {
 
   useEffect(() => {
     fetchSamlSecuritySettingsData();
-  }, [adminSamlSecurityContainer, fetchSamlSecuritySettingsData]);
+  }, [fetchSamlSecuritySettingsData]);
 
   return <SamlSecuritySettingContents />;
 };
 
 SamlSecurityManagement.propTypes = {
-  adminSamlSecurityContainer: PropTypes.instanceOf(AdminSamlSecurityContainer).isRequired,
+  adminSamlSecurityContainer: PropTypes.instanceOf(AdminSamlSecurityContainer)
+    .isRequired,
 };
 
-const SamlSecurityManagementWithUnstatedContainer = withUnstatedContainers(SamlSecurityManagement, [
-  AdminSamlSecurityContainer,
-]);
+const SamlSecurityManagementWithUnstatedContainer = withUnstatedContainers(
+  SamlSecurityManagement,
+  [AdminSamlSecurityContainer],
+);
 
 export default SamlSecurityManagementWithUnstatedContainer;

+ 369 - 116
apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx

@@ -1,16 +1,14 @@
 /* eslint-disable react/no-danger */
-import React, { useState, useEffect, useCallback } from 'react';
-
+import React, { useCallback, useEffect, useState } 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 { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -21,9 +19,7 @@ type Props = {
 };
 
 const SamlSecurityManagementContents = (props: Props) => {
-  const {
-    adminGeneralSecurityContainer, adminSamlSecurityContainer,
-  } = props;
+  const { adminGeneralSecurityContainer, adminSamlSecurityContainer } = props;
 
   const { t } = useTranslation('admin');
   const siteUrl = useSiteUrlWithEmptyValueWarn();
@@ -37,51 +33,60 @@ const SamlSecurityManagementContents = (props: Props) => {
       samlIssuer: adminSamlSecurityContainer.state.samlIssuer || '',
       samlCert: adminSamlSecurityContainer.state.samlCert || '',
       samlAttrMapId: adminSamlSecurityContainer.state.samlAttrMapId || '',
-      samlAttrMapUsername: adminSamlSecurityContainer.state.samlAttrMapUsername || '',
+      samlAttrMapUsername:
+        adminSamlSecurityContainer.state.samlAttrMapUsername || '',
       samlAttrMapMail: adminSamlSecurityContainer.state.samlAttrMapMail || '',
-      samlAttrMapFirstName: adminSamlSecurityContainer.state.samlAttrMapFirstName || '',
-      samlAttrMapLastName: adminSamlSecurityContainer.state.samlAttrMapLastName || '',
+      samlAttrMapFirstName:
+        adminSamlSecurityContainer.state.samlAttrMapFirstName || '',
+      samlAttrMapLastName:
+        adminSamlSecurityContainer.state.samlAttrMapLastName || '',
       samlABLCRule: adminSamlSecurityContainer.state.samlABLCRule || '',
     });
   }, [adminSamlSecurityContainer.state, reset]);
 
-  const onSubmit = useCallback(async(data) => {
-    try {
-      await adminSamlSecurityContainer.updateSamlSetting({
-        samlEntryPoint: data.samlEntryPoint,
-        samlIssuer: data.samlIssuer,
-        samlCert: data.samlCert,
-        samlAttrMapId: data.samlAttrMapId,
-        samlAttrMapUsername: data.samlAttrMapUsername,
-        samlAttrMapMail: data.samlAttrMapMail,
-        samlAttrMapFirstName: data.samlAttrMapFirstName,
-        samlAttrMapLastName: data.samlAttrMapLastName,
-        isSameUsernameTreatedAsIdenticalUser: adminSamlSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser,
-        isSameEmailTreatedAsIdenticalUser: adminSamlSecurityContainer.state.isSameEmailTreatedAsIdenticalUser,
-        samlABLCRule: data.samlABLCRule,
-      });
-      toastSuccess(t('security_settings.SAML.updated_saml'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-
-    try {
-      await adminGeneralSecurityContainer.retrieveSetupStratedies();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminSamlSecurityContainer, adminGeneralSecurityContainer, t]);
+  const onSubmit = useCallback(
+    async (data) => {
+      try {
+        await adminSamlSecurityContainer.updateSamlSetting({
+          samlEntryPoint: data.samlEntryPoint,
+          samlIssuer: data.samlIssuer,
+          samlCert: data.samlCert,
+          samlAttrMapId: data.samlAttrMapId,
+          samlAttrMapUsername: data.samlAttrMapUsername,
+          samlAttrMapMail: data.samlAttrMapMail,
+          samlAttrMapFirstName: data.samlAttrMapFirstName,
+          samlAttrMapLastName: data.samlAttrMapLastName,
+          isSameUsernameTreatedAsIdenticalUser:
+            adminSamlSecurityContainer.state
+              .isSameUsernameTreatedAsIdenticalUser,
+          isSameEmailTreatedAsIdenticalUser:
+            adminSamlSecurityContainer.state.isSameEmailTreatedAsIdenticalUser,
+          samlABLCRule: data.samlABLCRule,
+        });
+        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(pathUtils.removeTrailingSlash(siteUrl), '/passport/saml/callback');
+  const samlCallbackUrl = urljoin(
+    pathUtils.removeTrailingSlash(siteUrl),
+    '/passport/saml/callback',
+  );
 
   return (
     <React.Fragment>
-
       <h2 className="alert-anchor border-bottom">
         {t('security_settings.SAML.name')}
       </h2>
@@ -89,7 +94,12 @@ const SamlSecurityManagementContents = (props: Props) => {
       {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' }) }}
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+          dangerouslySetInnerHTML={{
+            __html: t('security_settings.SAML.note for the only env option', {
+              env: 'SAML_USES_ONLY_ENV_VARS_FOR_SOME_OPTIONS',
+            }),
+          }}
         />
       )}
 
@@ -101,34 +111,61 @@ const SamlSecurityManagementContents = (props: Props) => {
               className="form-check-input"
               type="checkbox"
               checked={adminGeneralSecurityContainer.state.isSamlEnabled}
-              onChange={() => { adminGeneralSecurityContainer.switchIsSamlEnabled() }}
+              onChange={() => {
+                adminGeneralSecurityContainer.switchIsSamlEnabled();
+              }}
               disabled={adminSamlSecurityContainer.state.useOnlyEnvVars}
             />
-            <label className="form-label form-check-label" htmlFor="isSamlEnabled">
+            <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>}
+          {!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>
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="samlCallbackUrl"
+        >
+          {t('security_settings.callback_URL')}
+        </label>
         <div className="col-md-6">
           <input
+            id="samlCallbackUrl"
             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>
+          <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' }) }}
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                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>
           )}
@@ -137,32 +174,44 @@ const SamlSecurityManagementContents = (props: Props) => {
 
       {isSamlEnabled && (
         <form onSubmit={handleSubmit(onSubmit)}>
-
-          {(adminSamlSecurityContainer.state.missingMandatoryConfigKeys.length !== 0) && (
+          {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>;
-                })}
+                {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>
 
-          <h3 className="alert-anchor border-bottom mb-3">
-            Basic Settings
-          </h3>
-
-          <table className={`table settings-table ${useOnlyEnvVars && 'use-only-env-vars'}`}>
+          <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>
+              <tr>
+                <th></th>
+                <th>Database</th>
+                <th>Environment variables</th>
+              </tr>
             </thead>
             <tbody>
               <tr>
@@ -183,7 +232,15 @@ const SamlSecurityManagementContents = (props: Props) => {
                     readOnly
                   />
                   <p className="form-text text-muted">
-                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ENTRY_POINT' }) }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      dangerouslySetInnerHTML={{
+                        __html: t(
+                          'security_settings.SAML.Use env var if empty',
+                          { env: 'SAML_ENTRY_POINT' },
+                        ),
+                      }}
+                    />
                   </p>
                 </td>
               </tr>
@@ -205,7 +262,15 @@ const SamlSecurityManagementContents = (props: Props) => {
                     readOnly
                   />
                   <p className="form-text text-muted">
-                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ISSUER' }) }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      dangerouslySetInnerHTML={{
+                        __html: t(
+                          'security_settings.SAML.Use env var if empty',
+                          { env: 'SAML_ISSUER' },
+                        ),
+                      }}
+                    />
                   </p>
                 </td>
               </tr>
@@ -219,14 +284,13 @@ const SamlSecurityManagementContents = (props: Props) => {
                     {...register('samlCert')}
                   />
                   <p>
-                    <small>
-                      {t('security_settings.SAML.cert_detail')}
-                    </small>
+                    <small>{t('security_settings.SAML.cert_detail')}</small>
                   </p>
                   <div>
                     <small>
                       e.g.
-                      <pre className="card custom-card">{`-----BEGIN CERTIFICATE-----
+                      <pre className="card custom-card">
+                        {`-----BEGIN CERTIFICATE-----
 MIICBzCCAXACCQD4US7+0A/b/zANBgkqhkiG9w0BAQsFADBIMQswCQYDVQQGEwJK
 UDEOMAwGA1UECAwFVG9reW8xFTATBgNVBAoMDFdFU0VFSywgSW5jLjESMBAGA1UE
 ...
@@ -246,7 +310,15 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     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' }) }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      dangerouslySetInnerHTML={{
+                        __html: t(
+                          'security_settings.SAML.Use env var if empty',
+                          { env: 'SAML_CERT' },
+                        ),
+                      }}
+                    />
                   </p>
                 </td>
               </tr>
@@ -264,7 +336,11 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
               <col className="from-env-vars" />
             </colgroup>
             <thead>
-              <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+              <tr>
+                <th></th>
+                <th>Database</th>
+                <th>Environment variables</th>
+              </tr>
             </thead>
             <tbody>
               <tr>
@@ -276,9 +352,7 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     {...register('samlAttrMapId')}
                   />
                   <p className="form-text text-muted">
-                    <small>
-                      {t('security_settings.SAML.id_detail')}
-                    </small>
+                    <small>{t('security_settings.SAML.id_detail')}</small>
                   </p>
                 </td>
                 <td>
@@ -289,7 +363,15 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     readOnly
                   />
                   <p className="form-text text-muted">
-                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ATTR_MAPPING_ID' }) }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      dangerouslySetInnerHTML={{
+                        __html: t(
+                          'security_settings.SAML.Use env var if empty',
+                          { env: 'SAML_ATTR_MAPPING_ID' },
+                        ),
+                      }}
+                    />
                   </p>
                 </td>
               </tr>
@@ -302,18 +384,33 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     {...register('samlAttrMapUsername')}
                   />
                   <p className="form-text text-muted">
-                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.username_detail') }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      dangerouslySetInnerHTML={{
+                        __html: t('security_settings.SAML.username_detail'),
+                      }}
+                    />
                   </p>
                 </td>
                 <td>
                   <input
                     className="form-control"
                     type="text"
-                    value={adminSamlSecurityContainer.state.envAttrMapUsername || ''}
+                    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' }) }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      dangerouslySetInnerHTML={{
+                        __html: t(
+                          'security_settings.SAML.Use env var if empty',
+                          { env: 'SAML_ATTR_MAPPING_USERNAME' },
+                        ),
+                      }}
+                    />
                   </p>
                 </td>
               </tr>
@@ -326,23 +423,42 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     {...register('samlAttrMapMail')}
                   />
                   <p className="form-text text-muted">
-                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.mapping_detail', { target: 'Email' }) }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      dangerouslySetInnerHTML={{
+                        __html: t('security_settings.SAML.mapping_detail', {
+                          target: 'Email',
+                        }),
+                      }}
+                    />
                   </p>
                 </td>
                 <td>
                   <input
                     className="form-control"
                     type="text"
-                    value={adminSamlSecurityContainer.state.envAttrMapMail || ''}
+                    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' }) }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      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>
+                <th>
+                  {t('security_settings.form_item_name.attrMapFirstName')}
+                </th>
                 <td>
                   <input
                     className="form-control"
@@ -351,21 +467,48 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                   />
                   <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') }) }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      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 || ''}
+                    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' }) }} />
+                      <span
+                        // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                        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' }) }} />
+                      <span
+                        // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                        dangerouslySetInnerHTML={{
+                          __html: t(
+                            'security_settings.Use default if both are empty',
+                            { target: 'firstName' },
+                          ),
+                        }}
+                      />
                     </small>
                   </p>
                 </td>
@@ -380,21 +523,48 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                   />
                   <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') }) }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      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 || ''}
+                    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' }) }} />
+                      <span
+                        // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                        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' }) }} />
+                      <span
+                        // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                        dangerouslySetInnerHTML={{
+                          __html: t(
+                            'security_settings.Use default if both are empty',
+                            { target: 'lastName' },
+                          ),
+                        }}
+                      />
                     </small>
                   </p>
                 </td>
@@ -412,17 +582,37 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                 id="bindByUserName-SAML"
                 className="form-check-input"
                 type="checkbox"
-                checked={adminSamlSecurityContainer.state.isSameUsernameTreatedAsIdenticalUser || false}
-                onChange={() => { adminSamlSecurityContainer.switchIsSameUsernameTreatedAsIdenticalUser() }}
+                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') }}
-              />
+              >
+                <span
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t(
+                      'security_settings.Treat username matching as identical',
+                    ),
+                  }}
+                />
+              </label>
             </div>
             <p className="form-text text-muted">
-              <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat username matching as identical_warn') }} />
+              <small
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t(
+                    'security_settings.Treat username matching as identical_warn',
+                  ),
+                }}
+              />
             </p>
           </div>
 
@@ -432,17 +622,37 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                 id="bindByEmail-SAML"
                 className="form-check-input"
                 type="checkbox"
-                checked={adminSamlSecurityContainer.state.isSameEmailTreatedAsIdenticalUser || false}
-                onChange={() => { adminSamlSecurityContainer.switchIsSameEmailTreatedAsIdenticalUser() }}
+                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') }}
-              />
+              >
+                <span
+                  // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                  dangerouslySetInnerHTML={{
+                    __html: t(
+                      'security_settings.Treat email matching as identical',
+                    ),
+                  }}
+                />
+              </label>
             </div>
             <p className="form-text text-muted">
-              <small dangerouslySetInnerHTML={{ __html: t('security_settings.Treat email matching as identical_warn') }} />
+              <small
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                dangerouslySetInnerHTML={{
+                  __html: t(
+                    'security_settings.Treat email matching as identical_warn',
+                  ),
+                }}
+              />
             </p>
           </div>
 
@@ -451,7 +661,14 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
           </h3>
 
           <p className="form-text text-muted">
-            <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.attr_based_login_control_detail') }} />
+            <small
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+              dangerouslySetInnerHTML={{
+                __html: t(
+                  'security_settings.SAML.attr_based_login_control_detail',
+                ),
+              }}
+            />
           </p>
 
           <table className="table settings-table">
@@ -461,13 +678,15 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
               <col className="from-env-vars" />
             </colgroup>
             <thead>
-              <tr><th></th><th>Database</th><th>Environment variables</th></tr>
+              <tr>
+                <th></th>
+                <th>Database</th>
+                <th>Environment variables</th>
+              </tr>
             </thead>
             <tbody>
               <tr>
-                <th>
-                  { t('security_settings.form_item_name.ABLCRule') }
-                </th>
+                <th>{t('security_settings.form_item_name.ABLCRule')}</th>
                 <td>
                   <textarea
                     className="form-control"
@@ -481,8 +700,12 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                         target="_blank"
                         rel="noreferer noreferrer"
                       >
-                        Apache Lucene - Query Parser Syntax <span className="growi-custom-icons">external_link</span>
-                      </a>.
+                        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">
@@ -496,14 +719,36 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                           >
                             <span className="material-symbols-outlined me-1">
                               {isHelpOpened ? 'expand_more' : 'chevron_right'}
-                            </span> Show more...
+                            </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') }} />
+                            <p
+                              // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                              dangerouslySetInnerHTML={{
+                                __html: t(
+                                  'security_settings.SAML.attr_based_login_control_rule_help',
+                                ),
+                              }}
+                            />
+                            <p
+                              // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                              dangerouslySetInnerHTML={{
+                                __html: t(
+                                  'security_settings.SAML.attr_based_login_control_rule_example1',
+                                ),
+                              }}
+                            />
+                            <p
+                              // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                              dangerouslySetInnerHTML={{
+                                __html: t(
+                                  'security_settings.SAML.attr_based_login_control_rule_example2',
+                                ),
+                              }}
+                            />
                           </div>
                         </Collapse>
                       </div>
@@ -517,7 +762,15 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                     readOnly
                   />
                   <p className="form-text text-muted">
-                    <small dangerouslySetInnerHTML={{ __html: t('security_settings.SAML.Use env var if empty', { env: 'SAML_ABLC_RULE' }) }} />
+                    <small
+                      // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                      dangerouslySetInnerHTML={{
+                        __html: t(
+                          'security_settings.SAML.Use env var if empty',
+                          { env: 'SAML_ABLC_RULE' },
+                        ),
+                      }}
+                    />
                   </p>
                 </td>
               </tr>
@@ -529,23 +782,23 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
               <button
                 type="submit"
                 className="btn btn-primary"
-                disabled={adminSamlSecurityContainer.state.retrieveError != null}
+                disabled={
+                  adminSamlSecurityContainer.state.retrieveError != null
+                }
               >
                 {t('Update')}
               </button>
             </div>
           </div>
-
         </form>
       )}
-
     </React.Fragment>
   );
 };
 
-const SamlSecurityManagementContentsWrapper = withUnstatedContainers(SamlSecurityManagementContents, [
-  AdminGeneralSecurityContainer,
-  AdminSamlSecurityContainer,
-]);
+const SamlSecurityManagementContentsWrapper = withUnstatedContainers(
+  SamlSecurityManagementContents,
+  [AdminGeneralSecurityContainer, AdminSamlSecurityContainer],
+);
 
 export default SamlSecurityManagementContentsWrapper;

+ 10 - 9
apps/app/src/client/components/Admin/Security/SecurityManagement.tsx

@@ -1,25 +1,23 @@
-import React, { useEffect, useCallback } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import { toastError } from '~/client/util/toastr';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import SecurityManagementContents from './SecurityManagementContents';
 
 type Props = {
-  adminGeneralSecurityContainer: AdminGeneralSecurityContainer
-}
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+};
 
 const SecurityManagement = (props: Props) => {
   const { adminGeneralSecurityContainer } = props;
 
-  const fetchGeneralSecuritySettingsData = useCallback(async() => {
+  const fetchGeneralSecuritySettingsData = useCallback(async () => {
     try {
       await adminGeneralSecurityContainer.retrieveSecurityData();
-    }
-    catch (err) {
+    } catch (err) {
       const errs = toArrayIfNot(err);
       toastError(errs);
     }
@@ -27,11 +25,14 @@ const SecurityManagement = (props: Props) => {
 
   useEffect(() => {
     fetchGeneralSecuritySettingsData();
-  }, [adminGeneralSecurityContainer, fetchGeneralSecuritySettingsData]);
+  }, [fetchGeneralSecuritySettingsData]);
 
   return <SecurityManagementContents />;
 };
 
-const SecurityManagementWithUnstatedContainer = withUnstatedContainers(SecurityManagement, [AdminGeneralSecurityContainer]);
+const SecurityManagementWithUnstatedContainer = withUnstatedContainers(
+  SecurityManagement,
+  [AdminGeneralSecurityContainer],
+);
 
 export default SecurityManagementWithUnstatedContainer;

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

@@ -1,11 +1,9 @@
-import React, { useMemo, useState } from 'react';
-
-import { useTranslation } from 'next-i18next';
+import React, { useState } from 'react';
 import Link from 'next/link';
+import { useTranslation } from 'next-i18next';
 import { TabContent, TabPane } from 'reactstrap';
 
 import CustomNav from '../../CustomNavigation/CustomNav';
-
 import GitHubSecuritySetting from './GitHubSecuritySetting';
 import GoogleSecuritySetting from './GoogleSecuritySetting';
 import LdapSecuritySetting from './LdapSecuritySetting';
@@ -15,47 +13,65 @@ import SamlSecuritySetting from './SamlSecuritySetting';
 import { SecuritySetting } from './SecuritySetting';
 import ShareLinkSetting from './ShareLinkSetting';
 
+const PassportLocalIcon = () => (
+  <span className="material-symbols-outlined">groups</span>
+);
+const PassportLdapIcon = () => (
+  <span className="material-symbols-outlined">network_node</span>
+);
+const PassportSamlIcon = () => (
+  <span className="material-symbols-outlined">key</span>
+);
+const PassportOidcIcon = () => (
+  <span className="material-symbols-outlined">key</span>
+);
+const PassportGoogleIcon = () => (
+  <span className="growi-custom-icons align-bottom">google</span>
+);
+const PassportGitHubIcon = () => (
+  <span className="growi-custom-icons align-bottom">github</span>
+);
+
+const navTabMapping = {
+  passport_local: {
+    Icon: PassportLocalIcon,
+    i18n: 'ID/Pass',
+  },
+  passport_ldap: {
+    Icon: PassportLdapIcon,
+    i18n: 'LDAP',
+  },
+  passport_saml: {
+    Icon: PassportSamlIcon,
+    i18n: 'SAML',
+  },
+  passport_oidc: {
+    Icon: PassportOidcIcon,
+    i18n: 'OIDC',
+  },
+  passport_google: {
+    Icon: PassportGoogleIcon,
+    i18n: 'Google',
+  },
+  passport_github: {
+    Icon: PassportGitHubIcon,
+    i18n: 'GitHub',
+  },
+};
+
 const SecurityManagementContents = () => {
   const { t } = useTranslation('admin');
 
   const [activeTab, setActiveTab] = useState('passport_local');
-  const [activeComponents, setActiveComponents] = useState(new Set(['passport_local']));
+  const [activeComponents, setActiveComponents] = useState(
+    new Set(['passport_local']),
+  );
 
   const switchActiveTab = (selectedTab) => {
     setActiveTab(selectedTab);
     setActiveComponents(activeComponents.add(selectedTab));
   };
 
-  const navTabMapping = useMemo(() => {
-    return {
-      passport_local: {
-        Icon: () => <span className="material-symbols-outlined">groups</span>,
-        i18n: 'ID/Pass',
-      },
-      passport_ldap: {
-        Icon: () => <span className="material-symbols-outlined">network_node</span>,
-        i18n: 'LDAP',
-      },
-      passport_saml: {
-        Icon: () => <span className="material-symbols-outlined">key</span>,
-        i18n: 'SAML',
-      },
-      passport_oidc: {
-        Icon: () => <span className="material-symbols-outlined">key</span>,
-        i18n: 'OIDC',
-      },
-      passport_google: {
-        Icon: () => <span className="growi-custom-icons align-bottom">google</span>,
-        i18n: 'Google',
-      },
-      passport_github: {
-        Icon: () => <span className="growi-custom-icons align-bottom">github</span>,
-        i18n: 'GitHub',
-      },
-    };
-  }, []);
-
-
   return (
     <div data-testid="admin-security">
       <div className="mb-5">
@@ -67,22 +83,26 @@ const SecurityManagementContents = () => {
         <ShareLinkSetting />
       </div>
 
-
       {/* XSS configuration link */}
       <div className="mb-5">
-        <h2 className="border-bottom pb-2">{t('security_settings.xss_prevent_setting')}</h2>
+        <h2 className="border-bottom pb-2">
+          {t('security_settings.xss_prevent_setting')}
+        </h2>
         <div className="mt-4">
           <Link
             href="/admin/markdown/#preventXSS"
             style={{ fontSize: 'large' }}
           >
-            <span className="material-symbols-outlined me-1">login</span> {t('security_settings.xss_prevent_setting_link')}
+            <span className="material-symbols-outlined me-1">login</span>{' '}
+            {t('security_settings.xss_prevent_setting_link')}
           </Link>
         </div>
       </div>
 
       <div className="auth-mechanism-configurations">
-        <h2 className="border-bottom pb-2">{t('security_settings.Authentication mechanism settings')}</h2>
+        <h2 className="border-bottom pb-2">
+          {t('security_settings.Authentication mechanism settings')}
+        </h2>
         <CustomNav
           activeTab={activeTab}
           navTabMapping={navTabMapping}
@@ -104,16 +124,19 @@ const SecurityManagementContents = () => {
             {activeComponents.has('passport_oidc') && <OidcSecuritySetting />}
           </TabPane>
           <TabPane tabId="passport_google">
-            {activeComponents.has('passport_google') && <GoogleSecuritySetting />}
+            {activeComponents.has('passport_google') && (
+              <GoogleSecuritySetting />
+            )}
           </TabPane>
           <TabPane tabId="passport_github">
-            {activeComponents.has('passport_github') && <GitHubSecuritySetting />}
+            {activeComponents.has('passport_github') && (
+              <GitHubSecuritySetting />
+            )}
           </TabPane>
         </TabContent>
       </div>
     </div>
   );
-
 };
 
 export default SecurityManagementContents;

+ 20 - 7
apps/app/src/client/components/Admin/Security/SecuritySetting/CommentManageRightsSettings.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import type React from 'react';
 
 import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 
@@ -7,7 +7,10 @@ type Props = {
   t: (key: string) => string;
 };
 
-export const CommentManageRightsSettings: React.FC<Props> = ({ adminGeneralSecurityContainer, t }) => {
+export const CommentManageRightsSettings: React.FC<Props> = ({
+  adminGeneralSecurityContainer,
+  t,
+}) => {
   const { isRomUserAllowedToComment } = adminGeneralSecurityContainer.state;
 
   return (
@@ -30,22 +33,32 @@ export const CommentManageRightsSettings: React.FC<Props> = ({ adminGeneralSecur
               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')}
+                {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">
+            <div className="dropdown-menu">
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(false) }}
+                onClick={() => {
+                  adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(
+                    false,
+                  );
+                }}
               >
                 {t('security_settings.read_only_users_comment.deny')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(true) }}
+                onClick={() => {
+                  adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(
+                    true,
+                  );
+                }}
               >
                 {t('security_settings.read_only_users_comment.accept')}
               </button>

+ 19 - 7
apps/app/src/client/components/Admin/Security/SecuritySetting/PageAccessRightsSettings.tsx

@@ -1,5 +1,5 @@
 /* eslint-disable react/no-danger */
-import React from 'react';
+import type React from 'react';
 
 import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 
@@ -8,7 +8,10 @@ type Props = {
   t: (key: string, options?: Record<string, unknown>) => string;
 };
 
-export const PageAccessRightsSettings: React.FC<Props> = ({ adminGeneralSecurityContainer, t }) => {
+export const PageAccessRightsSettings: React.FC<Props> = ({
+  adminGeneralSecurityContainer,
+  t,
+}) => {
   const { currentRestrictGuestMode } = adminGeneralSecurityContainer.state;
 
   return (
@@ -31,22 +34,30 @@ export const PageAccessRightsSettings: React.FC<Props> = ({ adminGeneralSecurity
               aria-expanded="true"
             >
               <span className="float-start">
-                {currentRestrictGuestMode === 'Deny' && t('security_settings.guest_mode.deny')}
-                {currentRestrictGuestMode === 'Readonly' && t('security_settings.guest_mode.readonly')}
+                {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">
+            <div className="dropdown-menu">
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminGeneralSecurityContainer.changeRestrictGuestMode('Deny') }}
+                onClick={() => {
+                  adminGeneralSecurityContainer.changeRestrictGuestMode('Deny');
+                }}
               >
                 {t('security_settings.guest_mode.deny')}
               </button>
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { adminGeneralSecurityContainer.changeRestrictGuestMode('Readonly') }}
+                onClick={() => {
+                  adminGeneralSecurityContainer.changeRestrictGuestMode(
+                    'Readonly',
+                  );
+                }}
               >
                 {t('security_settings.guest_mode.readonly')}
               </button>
@@ -59,6 +70,7 @@ export const PageAccessRightsSettings: React.FC<Props> = ({ adminGeneralSecurity
               <br />
               {/* eslint-disable-next-line react/no-danger */}
               <b
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
                 dangerouslySetInnerHTML={{
                   __html: t('security_settings.Fixed by env var', {
                     key: 'FORCE_WIKI_MODE',

+ 321 - 170
apps/app/src/client/components/Admin/Security/SecuritySetting/PageDeleteRightsSettings.tsx

@@ -1,20 +1,23 @@
-import React, { useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback } from 'react';
 import { Collapse } from 'reactstrap';
 
 import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import {
-  PageDeleteConfigValue,
   type IPageDeleteConfigValue,
   type IPageDeleteConfigValueToProcessValidation,
+  PageDeleteConfigValue,
 } from '~/interfaces/page-delete-config';
-import { validateDeleteConfigs, prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
+import {
+  prepareDeleteConfigValuesForCalc,
+  validateDeleteConfigs,
+} from '~/utils/page-delete-config';
 
 import {
   DeletionType,
   type DeletionTypeValue,
-  getDeletionTypeForT,
   getDeleteConfigValueForT,
+  getDeletionTypeForT,
   isRecursiveDeletion,
   isTypeDeletion,
 } from './types';
@@ -24,7 +27,10 @@ type Props = {
   t: (key: string) => string;
 };
 
-export const PageDeleteRightsSettings: React.FC<Props> = ({ adminGeneralSecurityContainer, t }) => {
+export const PageDeleteRightsSettings: React.FC<Props> = ({
+  adminGeneralSecurityContainer,
+  t,
+}) => {
   const {
     currentPageDeletionAuthority,
     currentPageCompleteDeletionAuthority,
@@ -32,189 +38,291 @@ export const PageDeleteRightsSettings: React.FC<Props> = ({ adminGeneralSecurity
     currentPageRecursiveCompleteDeletionAuthority,
   } = adminGeneralSecurityContainer.state;
 
-  const getRecursiveDeletionConfigState = useCallback((deletionType: DeletionTypeValue) => {
-    if (isTypeDeletion(deletionType)) {
+  const getRecursiveDeletionConfigState = useCallback(
+    (deletionType: DeletionTypeValue) => {
+      if (isTypeDeletion(deletionType)) {
+        return [
+          adminGeneralSecurityContainer.state
+            .currentPageRecursiveDeletionAuthority,
+          adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority,
+        ] as const;
+      }
+
       return [
-        adminGeneralSecurityContainer.state.currentPageRecursiveDeletionAuthority,
-        adminGeneralSecurityContainer.changePageRecursiveDeletionAuthority,
+        adminGeneralSecurityContainer.state
+          .currentPageRecursiveCompleteDeletionAuthority,
+        adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority,
       ] as const;
-    }
-
-    return [
-      adminGeneralSecurityContainer.state.currentPageRecursiveCompleteDeletionAuthority,
-      adminGeneralSecurityContainer.changePageRecursiveCompleteDeletionAuthority,
-    ] as const;
-  }, [adminGeneralSecurityContainer]);
+    },
+    [adminGeneralSecurityContainer],
+  );
 
-  const previousPageRecursiveAuthorityState = useCallback((deletionType: DeletionTypeValue) => {
-    return isTypeDeletion(deletionType)
-      ? adminGeneralSecurityContainer.state.previousPageRecursiveDeletionAuthority
-      : adminGeneralSecurityContainer.state.previousPageRecursiveCompleteDeletionAuthority;
-  }, [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;
-    }
+  const setPagePreviousRecursiveAuthorityState = useCallback(
+    (
+      deletionType: DeletionTypeValue,
+      previousState: IPageDeleteConfigValue | null,
+    ) => {
+      if (isTypeDeletion(deletionType)) {
+        adminGeneralSecurityContainer.changePreviousPageRecursiveDeletionAuthority(
+          previousState,
+        );
+        return;
+      }
 
-    adminGeneralSecurityContainer.changePreviousPageRecursiveCompleteDeletionAuthority(previousState);
-  }, [adminGeneralSecurityContainer]);
+      adminGeneralSecurityContainer.changePreviousPageRecursiveCompleteDeletionAuthority(
+        previousState,
+      );
+    },
+    [adminGeneralSecurityContainer],
+  );
 
-  const expandDeleteOptionsState = useCallback((deletionType: DeletionTypeValue) => {
-    return isTypeDeletion(deletionType)
-      ? adminGeneralSecurityContainer.state.expandOtherOptionsForDeletion
-      : adminGeneralSecurityContainer.state.expandOtherOptionsForCompleteDeletion;
-  }, [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 setExpandOtherDeleteOptionsState = useCallback(
+    (deletionType: DeletionTypeValue, bool: boolean) => {
+      if (isTypeDeletion(deletionType)) {
+        adminGeneralSecurityContainer.switchExpandOtherOptionsForDeletion(bool);
+        return;
+      }
+      adminGeneralSecurityContainer.switchExpandOtherOptionsForCompleteDeletion(
+        bool,
+      );
+    },
+    [adminGeneralSecurityContainer],
+  );
 
-  const setDeletionConfigState = useCallback((
+  const setDeletionConfigState = useCallback(
+    (
       newState: IPageDeleteConfigValue,
       setState: (value: IPageDeleteConfigValue) => void,
       deletionType: DeletionTypeValue,
-  ) => {
-    setState(newState);
+    ) => {
+      setState(newState);
 
-    if (previousPageRecursiveAuthorityState(deletionType) !== null) {
-      setPagePreviousRecursiveAuthorityState(deletionType, null);
-    }
+      if (previousPageRecursiveAuthorityState(deletionType) !== null) {
+        setPagePreviousRecursiveAuthorityState(deletionType, null);
+      }
 
-    if (isRecursiveDeletion(deletionType)) {
-      return;
-    }
+      if (isRecursiveDeletion(deletionType)) {
+        return;
+      }
 
-    const [recursiveState, setRecursiveState] = getRecursiveDeletionConfigState(deletionType);
+      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 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((
+  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)
-            ? (
+    ) => {
+      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">
+            {isRecursiveDeletion(deletionType) ? (
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { setDeletionConfigState(PageDeleteConfigValue.Inherit, setState, deletionType) }}
+                onClick={() => {
+                  setDeletionConfigState(
+                    PageDeleteConfigValue.Inherit,
+                    setState,
+                    deletionType,
+                  );
+                }}
               >
                 {t('security_settings.inherit')}
               </button>
-            )
-            : (
+            ) : (
               <button
                 className="dropdown-item"
                 type="button"
-                onClick={() => { setDeletionConfigState(PageDeleteConfigValue.Anyone, setState, deletionType) }}
+                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>
+            <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>
-        <p className="form-text text-muted small">{t(`security_settings.${getDeletionTypeForT(deletionType)}_explanation`)}</p>
-      </div>
-    );
-  }, [t, setDeletionConfigState]);
+      );
+    },
+    [t, setDeletionConfigState],
+  );
 
-  const renderPageDeletePermission = useCallback((
+  const renderPageDeletePermission = useCallback(
+    (
       currentState: IPageDeleteConfigValue,
       setState: (value: IPageDeleteConfigValue) => void,
       deletionType: DeletionTypeValue,
       isButtonDisabled: boolean,
-  ) => {
-    const expandDeleteOptions = expandDeleteOptionsState(deletionType);
+    ) => {
+      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>
+      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)
-            ? (
+          <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>
-                  </>
+                {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)}
+                  onClick={() =>
+                    setExpandOtherDeleteOptionsState(
+                      deletionType,
+                      !expandDeleteOptions,
+                    )
+                  }
                 >
-                  <span className={`material-symbols-outlined me-1 ${expandDeleteOptions ? 'rotate-90' : ''}`}>navigate_next</span>
+                  <span
+                    className={`material-symbols-outlined me-1 ${expandDeleteOptions ? 'rotate-90' : ''}`}
+                  >
+                    navigate_next
+                  </span>
                   {t('security_settings.other_options')}
                 </button>
                 <Collapse isOpen={expandDeleteOptions}>
@@ -223,67 +331,110 @@ export const PageDeleteRightsSettings: React.FC<Props> = ({ adminGeneralSecurity
                       <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
+                          // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+                          dangerouslySetInnerHTML={{
+                            __html: t(
+                              'security_settings.page_delete_rights_caution',
+                            ),
+                          }}
+                        />
                       </span>
                     </p>
-                    {previousPageRecursiveAuthorityState(deletionType) !== null && (
+                    {previousPageRecursiveAuthorityState(deletionType) !==
+                      null && (
                       <div className="mb-3">
-                        <strong>{t('security_settings.forced_update_desc')}</strong>
-                        <code>{t(getDeleteConfigValueForT(previousPageRecursiveAuthorityState(deletionType)))}</code>
+                        <strong>
+                          {t('security_settings.forced_update_desc')}
+                        </strong>
+                        <code>
+                          {t(
+                            getDeleteConfigValueForT(
+                              previousPageRecursiveAuthorityState(deletionType),
+                            ),
+                          )}
+                        </code>
                       </div>
                     )}
-                    {renderPageDeletePermissionDropdown(currentState, setState, deletionType, isButtonDisabled)}
+                    {renderPageDeletePermissionDropdown(
+                      currentState,
+                      setState,
+                      deletionType,
+                      isButtonDisabled,
+                    )}
                   </div>
                 </Collapse>
               </>
             )}
+          </div>
         </div>
-      </div>
-    );
-  }, [
-    adminGeneralSecurityContainer,
-    expandDeleteOptionsState,
-    previousPageRecursiveAuthorityState,
-    renderPageDeletePermissionDropdown,
-    setExpandOtherDeleteOptionsState,
-    t,
-  ]);
+      );
+    },
+    [
+      adminGeneralSecurityContainer,
+      expandDeleteOptionsState,
+      previousPageRecursiveAuthorityState,
+      renderPageDeletePermissionDropdown,
+      setExpandOtherDeleteOptionsState,
+      t,
+    ],
+  );
 
-  const isButtonDisabledForDeletion = !validateDeleteConfigs(currentPageDeletionAuthority, PageDeleteConfigValue.AdminAndAuthor);
+  const isButtonDisabledForDeletion = !validateDeleteConfigs(
+    currentPageDeletionAuthority,
+    PageDeleteConfigValue.AdminAndAuthor,
+  );
 
-  const isButtonDisabledForCompleteDeletion = !validateDeleteConfigs(currentPageCompleteDeletionAuthority, 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],
+        [
+          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,
-      ))}
+      ].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],
+        [
+          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,
-      ))}
+      ].map((arr) =>
+        renderPageDeletePermission(
+          arr[0] as IPageDeleteConfigValue,
+          arr[1] as (value: IPageDeleteConfigValue) => void,
+          arr[2] as DeletionTypeValue,
+          arr[3] as boolean,
+        ),
+      )}
     </>
   );
 };

+ 39 - 16
apps/app/src/client/components/Admin/Security/SecuritySetting/PageListDisplaySettings.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import type React from 'react';
 
 import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 
@@ -7,7 +7,10 @@ type Props = {
   t: (key: string) => string;
 };
 
-export const PageListDisplaySettings: React.FC<Props> = ({ adminGeneralSecurityContainer, t }) => {
+export const PageListDisplaySettings: React.FC<Props> = ({
+  adminGeneralSecurityContainer,
+  t,
+}) => {
   return (
     <>
       <h4 className="alert-anchor">
@@ -47,24 +50,34 @@ export const PageListDisplaySettings: React.FC<Props> = ({ adminGeneralSecurityC
                     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')}
+                      {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">
+                  <div className="dropdown-menu">
                     <button
                       className="dropdown-item"
                       type="button"
-                      onClick={() => { adminGeneralSecurityContainer.changeOwnerRestrictionDisplayMode('Displayed') }}
+                      onClick={() => {
+                        adminGeneralSecurityContainer.changeOwnerRestrictionDisplayMode(
+                          'Displayed',
+                        );
+                      }}
                     >
                       {t('security_settings.always_displayed')}
                     </button>
                     <button
                       className="dropdown-item"
                       type="button"
-                      onClick={() => { adminGeneralSecurityContainer.changeOwnerRestrictionDisplayMode('Hidden') }}
+                      onClick={() => {
+                        adminGeneralSecurityContainer.changeOwnerRestrictionDisplayMode(
+                          'Hidden',
+                        );
+                      }}
                     >
                       {t('security_settings.always_hidden')}
                     </button>
@@ -84,24 +97,34 @@ export const PageListDisplaySettings: React.FC<Props> = ({ adminGeneralSecurityC
                     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')}
+                      {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">
+                  <div className="dropdown-menu">
                     <button
                       className="dropdown-item"
                       type="button"
-                      onClick={() => { adminGeneralSecurityContainer.changeGroupRestrictionDisplayMode('Displayed') }}
+                      onClick={() => {
+                        adminGeneralSecurityContainer.changeGroupRestrictionDisplayMode(
+                          'Displayed',
+                        );
+                      }}
                     >
                       {t('security_settings.always_displayed')}
                     </button>
                     <button
                       className="dropdown-item"
                       type="button"
-                      onClick={() => { adminGeneralSecurityContainer.changeGroupRestrictionDisplayMode('Hidden') }}
+                      onClick={() => {
+                        adminGeneralSecurityContainer.changeGroupRestrictionDisplayMode(
+                          'Hidden',
+                        );
+                      }}
                     >
                       {t('security_settings.always_hidden')}
                     </button>

+ 15 - 5
apps/app/src/client/components/Admin/Security/SecuritySetting/SessionMaxAgeSettings.tsx

@@ -1,5 +1,4 @@
-import React from 'react';
-
+import type React from 'react';
 import type { UseFormRegister } from 'react-hook-form';
 
 type Props = {
@@ -12,21 +11,32 @@ export const SessionMaxAgeSettings: React.FC<Props> = ({ register, t }) => {
     <>
       <h4>{t('security_settings.session')}</h4>
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="sessionMaxAge"
+        >
           {t('security_settings.max_age')}
         </label>
         <div className="col-md-8">
           <input
+            id="sessionMaxAge"
             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="form-text text-muted"
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+            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 className="material-symbols-outlined">info</span>{' '}
+              {t('security_settings.max_age_caution')}
             </span>
           </p>
         </div>

+ 44 - 13
apps/app/src/client/components/Admin/Security/SecuritySetting/UserHomepageDeletionSettings.tsx

@@ -1,5 +1,5 @@
 /* eslint-disable react/no-danger */
-import React from 'react';
+import type React from 'react';
 
 import type AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 
@@ -8,10 +8,15 @@ type Props = {
   t: (key: string) => string;
 };
 
-export const UserHomepageDeletionSettings: React.FC<Props> = ({ adminGeneralSecurityContainer, t }) => {
+export const UserHomepageDeletionSettings: React.FC<Props> = ({
+  adminGeneralSecurityContainer,
+  t,
+}) => {
   return (
     <>
-      <h4 className="mb-3">{t('security_settings.user_homepage_deletion.user_homepage_deletion')}</h4>
+      <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">
@@ -19,11 +24,21 @@ export const UserHomepageDeletionSettings: React.FC<Props> = ({ adminGeneralSecu
               type="checkbox"
               className="form-check-input"
               id="is-user-page-deletion-enabled"
-              checked={adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled}
-              onChange={() => { adminGeneralSecurityContainer.switchIsUsersHomepageDeletionEnabled() }}
+              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
+              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">
@@ -31,17 +46,33 @@ export const UserHomepageDeletionSettings: React.FC<Props> = ({ adminGeneralSecu
               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}
+              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
+              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') }}
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted translation markup
+            dangerouslySetInnerHTML={{
+              __html: t('security_settings.user_homepage_deletion.desc'),
+            }}
           />
         </div>
       </div>

+ 81 - 37
apps/app/src/client/components/Admin/Security/SecuritySetting/index.tsx

@@ -1,13 +1,12 @@
-import React, { useCallback, useEffect } from 'react';
-
+import type React from 'react';
+import { 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 { toastError, toastSuccess } from '~/client/util/toastr';
 
 import { withUnstatedContainers } from '../../../UnstatedUtils';
-
 import { CommentManageRightsSettings } from './CommentManageRightsSettings';
 import { PageAccessRightsSettings } from './PageAccessRightsSettings';
 import { PageDeleteRightsSettings } from './PageDeleteRightsSettings';
@@ -23,7 +22,9 @@ type Props = {
   adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
 };
 
-const SecuritySettingComponent: React.FC<Props> = ({ adminGeneralSecurityContainer }) => {
+const SecuritySettingComponent: React.FC<Props> = ({
+  adminGeneralSecurityContainer,
+}) => {
   const { t } = useTranslation('admin');
   const { register, handleSubmit, reset } = useForm<FormData>();
 
@@ -34,35 +35,56 @@ const SecuritySettingComponent: React.FC<Props> = ({ adminGeneralSecurityContain
     });
   }, [reset, adminGeneralSecurityContainer.state.sessionMaxAge]);
 
-  const onSubmit = useCallback(async(data: FormData) => {
-    try {
-      // Save all security settings with form data
-      await adminGeneralSecurityContainer.updateGeneralSecuritySetting({
-        sessionMaxAge: data.sessionMaxAge,
-        restrictGuestMode: adminGeneralSecurityContainer.state.currentRestrictGuestMode,
-        pageDeletionAuthority: adminGeneralSecurityContainer.state.currentPageDeletionAuthority,
-        pageCompleteDeletionAuthority: adminGeneralSecurityContainer.state.currentPageCompleteDeletionAuthority,
-        pageRecursiveDeletionAuthority: adminGeneralSecurityContainer.state.currentPageRecursiveDeletionAuthority,
-        pageRecursiveCompleteDeletionAuthority: adminGeneralSecurityContainer.state.currentPageRecursiveCompleteDeletionAuthority,
-        isAllGroupMembershipRequiredForPageCompleteDeletion: adminGeneralSecurityContainer.state.isAllGroupMembershipRequiredForPageCompleteDeletion,
-        hideRestrictedByGroup: adminGeneralSecurityContainer.state.currentGroupRestrictionDisplayMode === 'Hidden',
-        hideRestrictedByOwner: adminGeneralSecurityContainer.state.currentOwnerRestrictionDisplayMode === 'Hidden',
-        isUsersHomepageDeletionEnabled: adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled,
-        isForceDeleteUserHomepageOnUserDeletion: adminGeneralSecurityContainer.state.isForceDeleteUserHomepageOnUserDeletion,
-        isRomUserAllowedToComment: adminGeneralSecurityContainer.state.isRomUserAllowedToComment,
-      });
-      toastSuccess(t('security_settings.updated_general_security_setting'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminGeneralSecurityContainer, t]);
+  const onSubmit = useCallback(
+    async (data: FormData) => {
+      try {
+        // Save all security settings with form data
+        await adminGeneralSecurityContainer.updateGeneralSecuritySetting({
+          sessionMaxAge: data.sessionMaxAge,
+          restrictGuestMode:
+            adminGeneralSecurityContainer.state.currentRestrictGuestMode,
+          pageDeletionAuthority:
+            adminGeneralSecurityContainer.state.currentPageDeletionAuthority,
+          pageCompleteDeletionAuthority:
+            adminGeneralSecurityContainer.state
+              .currentPageCompleteDeletionAuthority,
+          pageRecursiveDeletionAuthority:
+            adminGeneralSecurityContainer.state
+              .currentPageRecursiveDeletionAuthority,
+          pageRecursiveCompleteDeletionAuthority:
+            adminGeneralSecurityContainer.state
+              .currentPageRecursiveCompleteDeletionAuthority,
+          isAllGroupMembershipRequiredForPageCompleteDeletion:
+            adminGeneralSecurityContainer.state
+              .isAllGroupMembershipRequiredForPageCompleteDeletion,
+          hideRestrictedByGroup:
+            adminGeneralSecurityContainer.state
+              .currentGroupRestrictionDisplayMode === 'Hidden',
+          hideRestrictedByOwner:
+            adminGeneralSecurityContainer.state
+              .currentOwnerRestrictionDisplayMode === 'Hidden',
+          isUsersHomepageDeletionEnabled:
+            adminGeneralSecurityContainer.state.isUsersHomepageDeletionEnabled,
+          isForceDeleteUserHomepageOnUserDeletion:
+            adminGeneralSecurityContainer.state
+              .isForceDeleteUserHomepageOnUserDeletion,
+          isRomUserAllowedToComment:
+            adminGeneralSecurityContainer.state.isRomUserAllowedToComment,
+        });
+        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}
+          {t('Error occurred')} :{' '}
+          {adminGeneralSecurityContainer.state.retrieveError}
         </p>
       </div>
     );
@@ -70,22 +92,41 @@ const SecuritySettingComponent: React.FC<Props> = ({ adminGeneralSecurityContain
 
   return (
     <div data-testid="admin-security-setting">
-      <h2 className="border-bottom mb-5">{t('security_settings.security_settings')}</h2>
+      <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} />
+          <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}
+              disabled={
+                adminGeneralSecurityContainer.state.retrieveError != null
+              }
             >
               {t('Update')}
             </button>
@@ -96,4 +137,7 @@ const SecuritySettingComponent: React.FC<Props> = ({ adminGeneralSecurityContain
   );
 };
 
-export const SecuritySetting = withUnstatedContainers(SecuritySettingComponent, [AdminGeneralSecurityContainer]);
+export const SecuritySetting = withUnstatedContainers(
+  SecuritySettingComponent,
+  [AdminGeneralSecurityContainer],
+);

+ 22 - 7
apps/app/src/client/components/Admin/Security/SecuritySetting/types.ts

@@ -1,4 +1,7 @@
-import { PageDeleteConfigValue, type IPageDeleteConfigValue } from '~/interfaces/page-delete-config';
+import {
+  type IPageDeleteConfigValue,
+  PageDeleteConfigValue,
+} from '~/interfaces/page-delete-config';
 
 export const DeletionTypeForT = Object.freeze({
   Deletion: 'deletion',
@@ -15,9 +18,11 @@ export const DeletionType = Object.freeze({
 } as const);
 
 export type DeletionTypeKey = keyof typeof DeletionType;
-export type DeletionTypeValue = typeof DeletionType[DeletionTypeKey];
+export type DeletionTypeValue = (typeof DeletionType)[DeletionTypeKey];
 
-export const getDeletionTypeForT = (deletionType: DeletionTypeValue): string => {
+export const getDeletionTypeForT = (
+  deletionType: DeletionTypeValue,
+): string => {
   switch (deletionType) {
     case DeletionType.Deletion:
       return DeletionTypeForT.Deletion;
@@ -30,7 +35,9 @@ export const getDeletionTypeForT = (deletionType: DeletionTypeValue): string =>
   }
 };
 
-export const getDeleteConfigValueForT = (deleteConfigValue: IPageDeleteConfigValue | null): string => {
+export const getDeleteConfigValueForT = (
+  deleteConfigValue: IPageDeleteConfigValue | null,
+): string => {
   switch (deleteConfigValue) {
     case PageDeleteConfigValue.Anyone:
     case null:
@@ -51,8 +58,13 @@ export const getDeleteConfigValueForT = (deleteConfigValue: IPageDeleteConfigVal
  * @param deletionType Deletion type
  * @returns boolean
  */
-export const isRecursiveDeletion = (deletionType: DeletionTypeValue): boolean => {
-  return deletionType === DeletionType.RecursiveDeletion || deletionType === DeletionType.RecursiveCompleteDeletion;
+export const isRecursiveDeletion = (
+  deletionType: DeletionTypeValue,
+): boolean => {
+  return (
+    deletionType === DeletionType.RecursiveDeletion ||
+    deletionType === DeletionType.RecursiveCompleteDeletion
+  );
 };
 
 /**
@@ -61,5 +73,8 @@ export const isRecursiveDeletion = (deletionType: DeletionTypeValue): boolean =>
  * @returns boolean
  */
 export const isTypeDeletion = (deletionType: DeletionTypeValue): boolean => {
-  return deletionType === DeletionType.Deletion || deletionType === DeletionType.RecursiveDeletion;
+  return (
+    deletionType === DeletionType.Deletion ||
+    deletionType === DeletionType.RecursiveDeletion
+  );
 };

+ 71 - 57
apps/app/src/client/components/Admin/Security/ShareLinkSetting.tsx

@@ -1,34 +1,28 @@
-import React, {
-  useCallback, useEffect, useState,
-} from 'react';
-
+import React, { useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import { apiv3Delete } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import ShareLinkList from '../../PageAccessoriesModal/ShareLink/ShareLinkList';
 import PaginationWrapper from '../../PaginationWrapper';
 import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import DeleteAllShareLinksModal from './DeleteAllShareLinksModal';
 
 type PagerProps = {
-  activePage: number,
-  pagingHandler: (page: number) => Promise<void>,
-  totalLinks: number,
-  limit: number,
-}
+  activePage: number;
+  pagingHandler: (page: number) => Promise<void>;
+  totalLinks: number;
+  limit: number;
+};
 
 type ShareLinkSettingProps = {
-  adminGeneralSecurityContainer: AdminGeneralSecurityContainer,
-}
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer;
+};
 
 const Pager = (props: PagerProps) => {
-  const {
-    activePage, pagingHandler, totalLinks, limit,
-  } = props;
+  const { activePage, pagingHandler, totalLinks, limit } = props;
 
   return (
     <PaginationWrapper
@@ -43,66 +37,81 @@ const Pager = (props: PagerProps) => {
 };
 
 const ShareLinkSetting = (props: ShareLinkSettingProps) => {
-
   const { t } = useTranslation('admin');
   const { adminGeneralSecurityContainer } = props;
   const {
-    shareLinks, shareLinksActivePage, totalshareLinks, shareLinksPagingLimit,
-    disableLinkSharing, setupStrategies,
+    shareLinks,
+    shareLinksActivePage,
+    totalshareLinks,
+    shareLinksPagingLimit,
+    disableLinkSharing,
+    setupStrategies,
   } = adminGeneralSecurityContainer.state;
-  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>();
-
-  const getShareLinkList = useCallback(async(page: number) => {
-    try {
-      await adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [adminGeneralSecurityContainer]);
+  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] =
+    useState<boolean>();
+
+  const getShareLinkList = useCallback(
+    async (page: number) => {
+      try {
+        await adminGeneralSecurityContainer.retrieveShareLinksByPagingNum(page);
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [adminGeneralSecurityContainer],
+  );
 
   // for Next routing
   useEffect(() => {
     getShareLinkList(1);
   }, [getShareLinkList]);
 
-  const deleteAllLinksButtonHandler = useCallback(async() => {
+  const deleteAllLinksButtonHandler = useCallback(async () => {
     try {
       const res = await apiv3Delete('/share-links/all');
       const { deletedCount } = res.data;
-      toastSuccess(t('toaster.remove_share_link', { count: deletedCount, ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.remove_share_link', { count: deletedCount, ns: 'commons' }),
+      );
+    } catch (err) {
       toastError(err);
     }
     getShareLinkList(1);
   }, [getShareLinkList, t]);
 
-  const deleteLinkById = useCallback(async(shareLinkId: string) => {
-    try {
-      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
-      const { deletedShareLink } = res.data;
-      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id, ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-    getShareLinkList(shareLinksActivePage);
-  }, [shareLinksActivePage, getShareLinkList, t]);
+  const deleteLinkById = useCallback(
+    async (shareLinkId: string) => {
+      try {
+        const res = await apiv3Delete(`/share-links/${shareLinkId}`);
+        const { deletedShareLink } = res.data;
+        toastSuccess(
+          t('toaster.remove_share_link_success', {
+            shareLinkId: deletedShareLink._id,
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+      }
+      getShareLinkList(shareLinksActivePage);
+    },
+    [shareLinksActivePage, getShareLinkList, t],
+  );
 
-  const switchDisableLinkSharing = useCallback(async() => {
+  const switchDisableLinkSharing = useCallback(async () => {
     try {
       await adminGeneralSecurityContainer.switchDisableLinkSharing();
       toastSuccess(t('toaster.switch_disable_link_sharing_success'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [adminGeneralSecurityContainer, t]);
 
   return (
     <>
-      <h2 className="alert-anchor border-bottom mb-4">{t('security_settings.share_link_management')}</h2>
+      <h2 className="alert-anchor border-bottom mb-4">
+        {t('security_settings.share_link_management')}
+      </h2>
       <h4>{t('security_settings.share_link_rights')}</h4>
       <div className="row mt-4 mb-5">
         <div className="col-6 offset-3">
@@ -114,18 +123,23 @@ const ShareLinkSetting = (props: ShareLinkSettingProps) => {
               checked={!disableLinkSharing}
               onChange={() => switchDisableLinkSharing()}
             />
-            <label className="form-label form-check-label" htmlFor="disableLinkSharing">
+            <label
+              className="form-label form-check-label"
+              htmlFor="disableLinkSharing"
+            >
               {t('security_settings.enable_link_sharing')}
             </label>
           </div>
           {!setupStrategies.includes('local') && disableLinkSharing && (
-            <div className="badge bg-warning text-dark">{t('security_settings.setup_is_not_yet_complete')}</div>
+            <div className="badge bg-warning text-dark">
+              {t('security_settings.setup_is_not_yet_complete')}
+            </div>
           )}
         </div>
       </div>
       <h4>{t('security_settings.all_share_links')}</h4>
 
-      {(shareLinks.length !== 0) ? (
+      {shareLinks.length !== 0 ? (
         <>
           <Pager
             activePage={shareLinksActivePage}
@@ -139,10 +153,9 @@ const ShareLinkSetting = (props: ShareLinkSettingProps) => {
             isAdmin
           />
         </>
-      )
-        : (<p className="text-center">{t('security_settings.No_share_links')}</p>
-        )
-      }
+      ) : (
+        <p className="text-center">{t('security_settings.No_share_links')}</p>
+      )}
 
       <button
         className="pull-right btn btn-danger mt-2"
@@ -158,7 +171,6 @@ const ShareLinkSetting = (props: ShareLinkSettingProps) => {
         onClose={() => setIsDeleteConfirmModalShown(false)}
         onClickDeleteButton={deleteAllLinksButtonHandler}
       />
-
     </>
   );
 };
@@ -166,6 +178,8 @@ const ShareLinkSetting = (props: ShareLinkSettingProps) => {
 /**
  * Wrapper component for using unstated
  */
-const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSetting, [AdminGeneralSecurityContainer]);
+const ShareLinkSettingWrapper = withUnstatedContainers(ShareLinkSetting, [
+  AdminGeneralSecurityContainer,
+]);
 
 export default ShareLinkSettingWrapper;

+ 224 - 147
apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx

@@ -1,15 +1,21 @@
 import type { FC } from 'react';
 import { useCallback, useState } from 'react';
-
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { DropdownToggle } from 'reactstrap';
 
 import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import {
-  addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
+  addBookmarkToFolder,
+  addNewFolder,
+  hasChildren,
+  updateBookmarkFolder,
 } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
-import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
+import type {
+  BookmarkFolderItems,
+  DragItemDataType,
+  DragItemType,
+} from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import { useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
@@ -20,28 +26,43 @@ import { BookmarkItem } from './BookmarkItem';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 type BookmarkFolderItemProps = {
-  isReadOnlyUser: boolean
-  bookmarkFolder: BookmarkFolderItems
-  isOpen?: boolean
-  isOperable: boolean,
-  level: number
-  root: string
-  isUserHomepage?: boolean
-  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
-  bookmarkFolderTreeMutation: () => void
-}
-
-export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderItemProps) => {
+  isReadOnlyUser: boolean;
+  bookmarkFolder: BookmarkFolderItems;
+  isOpen?: boolean;
+  isOperable: boolean;
+  level: number;
+  root: string;
+  isUserHomepage?: boolean;
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void;
+  bookmarkFolderTreeMutation: () => void;
+};
 
+export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (
+  props: BookmarkFolderItemProps,
+) => {
   const BASE_FOLDER_PADDING = 15;
-  const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
+  const acceptedTypes: DragItemType[] = [
+    DRAG_ITEM_TYPE.FOLDER,
+    DRAG_ITEM_TYPE.BOOKMARK,
+  ];
   const {
-    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomepage,
-    onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
+    isReadOnlyUser,
+    bookmarkFolder,
+    isOpen: _isOpen = false,
+    isOperable,
+    level,
+    root,
+    isUserHomepage,
+    onClickDeleteMenuItemHandler,
+    bookmarkFolderTreeMutation,
   } = props;
 
   const {
-    name, _id: folderId, childFolder, parent, bookmarks,
+    name,
+    _id: folderId,
+    childFolder,
+    parent,
+    bookmarks,
   } = bookmarkFolder;
 
   const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
@@ -49,13 +70,14 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
 
-  const { open: openDeleteBookmarkFolderModal } = useDeleteBookmarkFolderModalActions();
+  const { open: openDeleteBookmarkFolderModal } =
+    useDeleteBookmarkFolderModalActions();
 
   const childrenExists = hasChildren({ childFolder, bookmarks });
 
   const paddingLeft = BASE_FOLDER_PADDING * level;
 
-  const loadChildFolder = useCallback(async() => {
+  const loadChildFolder = useCallback(async () => {
     setIsOpen(!isOpen);
     setTargetFolder(folderId);
   }, [folderId, isOpen]);
@@ -66,95 +88,127 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   }, []);
 
   // Rename for bookmark folder handler
-  const rename = useCallback(async(folderName: string) => {
-    if (folderName.trim() === '') {
-      return cancel();
-    }
+  const rename = useCallback(
+    async (folderName: string) => {
+      if (folderName.trim() === '') {
+        return cancel();
+      }
 
-    try {
-      // TODO: do not use any type
-      await updateBookmarkFolder(folderId, folderName.trim(), parent as any, childFolder);
-      bookmarkFolderTreeMutation();
-      setIsRenameAction(false);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [bookmarkFolderTreeMutation, cancel, childFolder, folderId, parent]);
+      try {
+        // TODO: do not use any type
+        await updateBookmarkFolder(
+          folderId,
+          folderName.trim(),
+          parent as any,
+          childFolder,
+        );
+        bookmarkFolderTreeMutation();
+        setIsRenameAction(false);
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [bookmarkFolderTreeMutation, cancel, childFolder, folderId, parent],
+  );
 
   // Create new folder / subfolder handler
-  const create = useCallback(async(folderName: string) => {
-    if (folderName.trim() === '') {
-      return cancel();
-    }
+  const create = useCallback(
+    async (folderName: string) => {
+      if (folderName.trim() === '') {
+        return cancel();
+      }
 
-    try {
-      await addNewFolder(folderName.trim(), targetFolder);
-      setIsOpen(true);
-      setIsCreateAction(false);
-      bookmarkFolderTreeMutation();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [bookmarkFolderTreeMutation, cancel, targetFolder]);
+      try {
+        await addNewFolder(folderName.trim(), targetFolder);
+        setIsOpen(true);
+        setIsCreateAction(false);
+        bookmarkFolderTreeMutation();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [bookmarkFolderTreeMutation, cancel, targetFolder],
+  );
 
-  const onClickPlusButton = useCallback(async(e) => {
-    e.stopPropagation();
-    if (!isOpen && childrenExists) {
-      setIsOpen(true);
-    }
-    setIsCreateAction(true);
-  }, [childrenExists, isOpen]);
+  const onClickPlusButton = useCallback(
+    async (e) => {
+      e.stopPropagation();
+      if (!isOpen && childrenExists) {
+        setIsOpen(true);
+      }
+      setIsCreateAction(true);
+    },
+    [childrenExists, isOpen],
+  );
 
-  const itemDropHandler = async(item: DragItemDataType, dragItemType: string | symbol | null) => {
+  const itemDropHandler = async (
+    item: DragItemDataType,
+    dragItemType: string | symbol | null,
+  ) => {
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
         if (item.bookmarkFolder != null) {
-          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.childFolder);
+          await updateBookmarkFolder(
+            item.bookmarkFolder._id,
+            item.bookmarkFolder.name,
+            bookmarkFolder._id,
+            item.bookmarkFolder.childFolder,
+          );
           bookmarkFolderTreeMutation();
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
-    }
-    else {
+    } else {
       try {
         if (item != null) {
           await addBookmarkToFolder(item._id, bookmarkFolder._id);
           bookmarkFolderTreeMutation();
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
     }
   };
 
-  const isDropable = (item: DragItemDataType, type: string | null | symbol): boolean => {
+  const isDropable = (
+    item: DragItemDataType,
+    type: string | null | symbol,
+  ): boolean => {
     if (type === DRAG_ITEM_TYPE.FOLDER) {
-      if (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
+      if (
+        item.bookmarkFolder.parent === bookmarkFolder._id ||
+        item.bookmarkFolder._id === bookmarkFolder._id
+      ) {
         return false;
       }
 
       // Maximum folder hierarchy of 2 levels
       // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
       // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
-      if (item.bookmarkFolder.childFolder.length !== 0 || bookmarkFolder.parent != null) {
+      if (
+        item.bookmarkFolder.childFolder.length !== 0 ||
+        bookmarkFolder.parent != null
+      ) {
         return false;
       }
 
       return item.root !== root || item.level >= level;
     }
 
-    if (item.parentFolder != null && item.parentFolder._id === bookmarkFolder._id) {
+    if (
+      item.parentFolder != null &&
+      item.parentFolder._id === bookmarkFolder._id
+    ) {
       return false;
     }
     return true;
   };
 
-  const triangleBtnClassName = (isOpen: boolean, childrenExists: boolean): string => {
+  const triangleBtnClassName = (
+    isOpen: boolean,
+    childrenExists: boolean,
+  ): string => {
     if (!childrenExists) {
       return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
     }
@@ -162,41 +216,47 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   };
 
   const renderChildFolder = () => {
-    return isOpen && childFolder?.map((childFolder) => {
-      return (
-        <div key={childFolder._id} className="grw-foldertree-item-children">
-          <BookmarkFolderItem
-            key={childFolder._id}
+    return (
+      isOpen &&
+      childFolder?.map((childFolder) => {
+        return (
+          <div key={childFolder._id} className="grw-foldertree-item-children">
+            <BookmarkFolderItem
+              key={childFolder._id}
+              isReadOnlyUser={isReadOnlyUser}
+              isOperable={props.isOperable}
+              bookmarkFolder={childFolder}
+              level={level + 1}
+              root={root}
+              isUserHomepage={isUserHomepage}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
+              bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+            />
+          </div>
+        );
+      })
+    );
+  };
+
+  const renderBookmarkItem = () => {
+    return (
+      isOpen &&
+      bookmarks?.map((bookmark) => {
+        return (
+          <BookmarkItem
+            key={bookmark._id}
             isReadOnlyUser={isReadOnlyUser}
             isOperable={props.isOperable}
-            bookmarkFolder={childFolder}
+            bookmarkedPage={bookmark.page}
             level={level + 1}
-            root={root}
-            isUserHomepage={isUserHomepage}
+            parentFolder={bookmarkFolder}
+            canMoveToRoot
             onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           />
-        </div>
-      );
-    });
-  };
-
-  const renderBookmarkItem = () => {
-    return isOpen && bookmarks?.map((bookmark) => {
-      return (
-        <BookmarkItem
-          key={bookmark._id}
-          isReadOnlyUser={isReadOnlyUser}
-          isOperable={props.isOperable}
-          bookmarkedPage={bookmark.page}
-          level={level + 1}
-          parentFolder={bookmarkFolder}
-          canMoveToRoot
-          onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
-          bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
-        />
-      );
-    });
+        );
+      })
+    );
   };
 
   const onClickRenameHandler = useCallback(() => {
@@ -204,7 +264,9 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   }, []);
 
   const onClickDeleteHandler = useCallback(() => {
-    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (folderId) => {
+    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (
+      folderId,
+    ) => {
       if (typeof folderId !== 'string') {
         return;
       }
@@ -214,21 +276,39 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (bookmarkFolder == null) {
       return;
     }
-    openDeleteBookmarkFolderModal(bookmarkFolder, { onDeleted: bookmarkFolderDeleteHandler });
-  }, [bookmarkFolder, bookmarkFolderTreeMutation, openDeleteBookmarkFolderModal]);
-
-  const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
-    try {
-      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.childFolder);
-      bookmarkFolderTreeMutation();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [bookmarkFolder._id, bookmarkFolder.childFolder, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+    openDeleteBookmarkFolderModal(bookmarkFolder, {
+      onDeleted: bookmarkFolderDeleteHandler,
+    });
+  }, [
+    bookmarkFolder,
+    bookmarkFolderTreeMutation,
+    openDeleteBookmarkFolderModal,
+  ]);
 
+  const onClickMoveToRootHandlerForBookmarkFolderItemControl =
+    useCallback(async () => {
+      try {
+        await updateBookmarkFolder(
+          bookmarkFolder._id,
+          bookmarkFolder.name,
+          null,
+          bookmarkFolder.childFolder,
+        );
+        bookmarkFolderTreeMutation();
+      } catch (err) {
+        toastError(err);
+      }
+    }, [
+      bookmarkFolder._id,
+      bookmarkFolder.childFolder,
+      bookmarkFolder.name,
+      bookmarkFolderTreeMutation,
+    ]);
   return (
-    <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
+    <div
+      id={`grw-bookmark-folder-item-${folderId}`}
+      className="grw-foldertree-item-container"
+    >
       <DragAndDropWrapper
         key={folderId}
         type={acceptedTypes}
@@ -240,23 +320,8 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
       >
         <li
           className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded-1"
-          onClick={loadChildFolder}
           style={{ paddingLeft }}
         >
-          <div className="grw-triangle-container d-flex justify-content-center">
-            <button
-              type="button"
-              className={triangleBtnClassName(isOpen, childrenExists)}
-              onClick={loadChildFolder}
-            >
-              <div className="d-flex justify-content-center">
-                <span className="material-symbols-outlined fs-5">arrow_right</span>
-              </div>
-            </button>
-          </div>
-          <div>
-            <FolderIcon isOpen={isOpen} />
-          </div>
           {isRenameAction ? (
             <div className="flex-fill">
               <BookmarkFolderNameInput
@@ -266,27 +331,46 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               />
             </div>
           ) : (
-            <>
+            <button
+              type="button"
+              className="d-flex align-items-center flex-fill border-0 bg-transparent p-0 text-start"
+              onClick={loadChildFolder}
+            >
+              <div className="grw-triangle-container d-flex justify-content-center">
+                <span className={triangleBtnClassName(isOpen, childrenExists)}>
+                  <span className="material-symbols-outlined fs-5">
+                    arrow_right
+                  </span>
+                </span>
+              </div>
+              <div>
+                <FolderIcon isOpen={isOpen} />
+              </div>
               <div className="grw-foldertree-title-anchor ps-1">
                 <p className="text-truncate m-auto">{name}</p>
               </div>
-            </>
+            </button>
           )}
           {isOperable && (
             <div className="grw-foldertree-control d-flex">
               <BookmarkFolderItemControl
                 onClickRename={onClickRenameHandler}
                 onClickDelete={onClickDeleteHandler}
-                onClickMoveToRoot={bookmarkFolder.parent != null
-                  ? onClickMoveToRootHandlerForBookmarkFolderItemControl
-                  : undefined
+                onClickMoveToRoot={
+                  bookmarkFolder.parent != null
+                    ? onClickMoveToRootHandlerForBookmarkFolderItemControl
+                    : undefined
                 }
               >
-                <div onClick={e => e.stopPropagation()}>
-                  <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
-                    <span className="material-symbols-outlined">more_vert</span>
-                  </DropdownToggle>
-                </div>
+                <DropdownToggle
+                  color="transparent"
+                  className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1"
+                  onClick={(event) => {
+                    event.stopPropagation();
+                  }}
+                >
+                  <span className="material-symbols-outlined">more_vert</span>
+                </DropdownToggle>
               </BookmarkFolderItemControl>
               {/* Maximum folder hierarchy of 2 levels */}
               {!(bookmarkFolder.parent != null) && (
@@ -304,17 +388,10 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         </li>
       </DragAndDropWrapper>
       {isCreateAction && (
-        <BookmarkFolderNameInput
-          onSubmit={create}
-          onCancel={cancel}
-        />
+        <BookmarkFolderNameInput onSubmit={create} onCancel={cancel} />
       )}
-      {
-        renderChildFolder()
-      }
-      {
-        renderBookmarkItem()
-      }
+      {renderChildFolder()}
+      {renderBookmarkItem()}
     </div>
   );
 };

+ 27 - 19
apps/app/src/client/components/Bookmarks/BookmarkFolderItemControl.tsx

@@ -1,15 +1,17 @@
-import React, { useState, type JSX } from 'react';
-
+import React, { type JSX, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import {
-  Dropdown, DropdownItem, DropdownMenu, DropdownToggle,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 
 export const BookmarkFolderItemControl: React.FC<{
-  children?: React.ReactNode
-  onClickMoveToRoot?: () => Promise<void>
-  onClickRename: () => void
-  onClickDelete: () => void
+  children?: React.ReactNode;
+  onClickMoveToRoot?: () => Promise<void>;
+  onClickRename: () => void;
+  onClickDelete: () => void;
 }> = ({
   children,
   onClickMoveToRoot,
@@ -21,23 +23,25 @@ export const BookmarkFolderItemControl: React.FC<{
 
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
-      { children ?? (
-        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+      {children ?? (
+        <DropdownToggle
+          color="transparent"
+          className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center"
+        >
           <span className="material-symbols-outlined">more_horiz</span>
         </DropdownToggle>
-      ) }
+      )}
 
-      { isOpen && (
-        <DropdownMenu
-          container="body"
-          style={{ zIndex: 1055 }}
-        >
+      {isOpen && (
+        <DropdownMenu container="body" style={{ zIndex: 1055 }}>
           {onClickMoveToRoot && (
             <DropdownItem
               onClick={onClickMoveToRoot}
               className="grw-page-control-dropdown-item"
             >
-              <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
+              <span className="material-symbols-outlined grw-page-control-dropdown-icon">
+                bookmark
+              </span>
               {t('bookmark_folder.move_to_root')}
             </DropdownItem>
           )}
@@ -45,7 +49,9 @@ export const BookmarkFolderItemControl: React.FC<{
             onClick={onClickRename}
             className="grw-page-control-dropdown-item"
           >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">redo</span>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+              redo
+            </span>
             {t('Rename')}
           </DropdownItem>
 
@@ -55,11 +61,13 @@ export const BookmarkFolderItemControl: React.FC<{
             className="pt-2 grw-page-control-dropdown-item text-danger"
             onClick={onClickDelete}
           >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">delete</span>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+              delete
+            </span>
             {t('Delete')}
           </DropdownItem>
         </DropdownMenu>
-      ) }
+      )}
     </Dropdown>
   );
 };

+ 85 - 60
apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -1,11 +1,11 @@
-import React, {
-  useCallback, useMemo, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 
-import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
+import {
+  addBookmarkToFolder,
+  toggleBookmark,
+} from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
@@ -17,43 +17,45 @@ import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
 import styles from './BookmarkFolderMenu.module.scss';
 
 type BookmarkFolderMenuProps = {
-  isOpen: boolean,
-  pageId: string,
-  isBookmarked: boolean,
-  onToggle?: () => void,
-  onUnbookmark?: () => void,
-  children?: React.ReactNode,
-}
-
-export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element => {
-  const {
-    isOpen, pageId, isBookmarked, onToggle, onUnbookmark, children,
-  } = props;
+  isOpen: boolean;
+  pageId: string;
+  isBookmarked: boolean;
+  onToggle?: () => void;
+  onUnbookmark?: () => void;
+  children?: React.ReactNode;
+};
+
+export const BookmarkFolderMenu = (
+  props: BookmarkFolderMenuProps,
+): JSX.Element => {
+  const { isOpen, pageId, isBookmarked, onToggle, onUnbookmark, children } =
+    props;
 
   const { t } = useTranslation();
 
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
 
   const currentUser = useCurrentUser();
-  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } =
+    useSWRxBookmarkFolderAndChild(currentUser?._id);
 
-  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { trigger: mutateCurrentUserBookmarks } =
+    useSWRMUTxCurrentUserBookmarks();
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
 
   const isBookmarkFolderExists = useMemo((): boolean => {
     return bookmarkFolders != null && bookmarkFolders.length > 0;
   }, [bookmarkFolders]);
 
-  const toggleBookmarkHandler = useCallback(async() => {
+  const toggleBookmarkHandler = useCallback(async () => {
     try {
       await toggleBookmark(pageId, isBookmarked);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [isBookmarked, pageId]);
 
-  const onUnbookmarkHandler = useCallback(async() => {
+  const onUnbookmarkHandler = useCallback(async () => {
     if (onUnbookmark != null) {
       onUnbookmark();
     }
@@ -62,9 +64,15 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
     mutateCurrentUserBookmarks();
     mutateBookmarkFolders();
     mutatePageInfo();
-  }, [onUnbookmark, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
-
-  const toggleHandler = useCallback(async() => {
+  }, [
+    onUnbookmark,
+    toggleBookmarkHandler,
+    mutateCurrentUserBookmarks,
+    mutateBookmarkFolders,
+    mutatePageInfo,
+  ]);
+
+  const toggleHandler = useCallback(async () => {
     // on close
     if (isOpen && bookmarkFolders != null) {
       bookmarkFolders.forEach((bookmarkFolder) => {
@@ -89,29 +97,48 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
         await toggleBookmarkHandler();
         mutateCurrentUserBookmarks();
         mutatePageInfo();
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
     }
-  },
-  [isOpen, bookmarkFolders, onToggle, selectedItem, isBookmarked, pageId, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutatePageInfo]);
-
-  const onMenuItemClickHandler = useCallback(async(e, itemId: string) => {
-    e.stopPropagation();
-
-    setSelectedItem(itemId);
+  }, [
+    isOpen,
+    bookmarkFolders,
+    onToggle,
+    selectedItem,
+    isBookmarked,
+    pageId,
+    toggleBookmarkHandler,
+    mutateCurrentUserBookmarks,
+    mutatePageInfo,
+  ]);
+
+  const onMenuItemClickHandler = useCallback(
+    async (e, itemId: string) => {
+      e.stopPropagation();
+
+      setSelectedItem(itemId);
 
-    try {
-      await addBookmarkToFolder(pageId, itemId === 'root' ? null : itemId);
-      mutateCurrentUserBookmarks();
-      mutateBookmarkFolders();
-      mutatePageInfo();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [pageId, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
+      try {
+        await addBookmarkToFolder(pageId, itemId === 'root' ? null : itemId);
+        mutateCurrentUserBookmarks();
+        mutateBookmarkFolders();
+        mutatePageInfo();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [pageId, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo],
+  );
+  const onMenuItemKeyDownHandler = useCallback(
+    (itemId: string) => (event: React.KeyboardEvent<HTMLDivElement>) => {
+      if (event.key === 'Enter' || event.key === ' ') {
+        event.preventDefault();
+        onMenuItemClickHandler(event, itemId);
+      }
+    },
+    [onMenuItemClickHandler],
+  );
 
   const renderBookmarkMenuItem = () => {
     return (
@@ -122,9 +149,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           className="grw-bookmark-folder-menu-item text-danger text-truncate"
         >
           <span className="material-symbols-outlined">bookmark</span>{' '}
-          <span className="mx-2">
-            {t('bookmark_folder.cancel_bookmark')}
-          </span>
+          <span className="mx-2">{t('bookmark_folder.cancel_bookmark')}</span>
         </DropdownItem>
 
         {isBookmarkFolderExists && (
@@ -135,7 +160,8 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                 className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action px-4"
                 tabIndex={0}
                 role="menuitem"
-                onClick={e => onMenuItemClickHandler(e, 'root')}
+                onClick={(e) => onMenuItemClickHandler(e, 'root')}
+                onKeyDown={onMenuItemKeyDownHandler('root')}
               >
                 <BookmarkFolderMenuItem
                   itemId="root"
@@ -144,13 +170,14 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                 />
               </div>
             </div>
-            {bookmarkFolders?.map(folder => (
+            {bookmarkFolders?.map((folder) => (
               <React.Fragment key={`bookmark-folders-${folder._id}`}>
                 <div
                   className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-first list-group-item list-group-item-action"
                   tabIndex={0}
                   role="menuitem"
-                  onClick={e => onMenuItemClickHandler(e, folder._id)}
+                  onClick={(e) => onMenuItemClickHandler(e, folder._id)}
+                  onKeyDown={onMenuItemKeyDownHandler(folder._id)}
                 >
                   <BookmarkFolderMenuItem
                     itemId={folder._id}
@@ -158,13 +185,14 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     isSelected={selectedItem === folder._id}
                   />
                 </div>
-                {folder.childFolder?.map(child => (
+                {folder.childFolder?.map((child) => (
                   <div key={child._id}>
                     <div
                       className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-second list-group-item list-group-item-action"
                       tabIndex={0}
                       role="menuitem"
-                      onClick={e => onMenuItemClickHandler(e, child._id)}
+                      onClick={(e) => onMenuItemClickHandler(e, child._id)}
+                      onKeyDown={onMenuItemKeyDownHandler(child._id)}
                     >
                       <BookmarkFolderMenuItem
                         itemId={child._id}
@@ -183,13 +211,10 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
   };
 
   return (
-    <UncontrolledDropdown
-      isOpen={isOpen}
-      onToggle={toggleHandler}
-    >
+    <UncontrolledDropdown isOpen={isOpen} onToggle={toggleHandler}>
       {children}
 
-      { isOpen && (
+      {isOpen && (
         <DropdownMenu
           end
           persist
@@ -197,9 +222,9 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           container="body"
           className={`grw-bookmark-folder-menu ${styles['grw-bookmark-folder-menu']}`}
         >
-          { renderBookmarkMenuItem() }
+          {renderBookmarkMenuItem()}
         </DropdownMenu>
-      ) }
+      )}
     </UncontrolledDropdown>
   );
 };

+ 11 - 12
apps/app/src/client/components/Bookmarks/BookmarkFolderMenuItem.tsx

@@ -1,14 +1,10 @@
-import React from 'react';
+import type React from 'react';
 
 export const BookmarkFolderMenuItem: React.FC<{
-  itemId: string
-  itemName: string
-  isSelected: boolean
-}> = ({
-  itemId,
-  itemName,
-  isSelected,
-}) => {
+  itemId: string;
+  itemName: string;
+  isSelected: boolean;
+}> = ({ itemId, itemName, isSelected }) => {
   return (
     <div className="d-flex align-items-center grw-bookmark-folder-menu-item-title">
       <input
@@ -16,10 +12,13 @@ export const BookmarkFolderMenuItem: React.FC<{
         checked={isSelected}
         name="bookmark-folder-menu-item"
         id={`bookmark-folder-menu-item-${itemId}`}
-        onChange={e => e.stopPropagation()}
-        onClick={e => e.stopPropagation()}
+        onChange={(e) => e.stopPropagation()}
+        onClick={(e) => e.stopPropagation()}
       />
-      <label htmlFor={`bookmark-folder-menu-item-${itemId}`} className="p-2 m-0 form-label text-truncate">
+      <label
+        htmlFor={`bookmark-folder-menu-item-${itemId}`}
+        className="p-2 m-0 form-label text-truncate"
+      >
         {itemName}
       </label>
     </div>

+ 39 - 19
apps/app/src/client/components/Bookmarks/BookmarkFolderNameInput.tsx

@@ -1,19 +1,26 @@
 import type { ChangeEvent, JSX } from 'react';
 import { useCallback, useRef, useState } from 'react';
-
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import { debounce } from 'throttle-debounce';
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
-import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
-
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+
+import {
+  AutosizeSubmittableInput,
+  getAdjustedMaxWidthForAutosizeInput,
+} from '../Common/SubmittableInput';
 import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
 
-
-type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
+type Props = Pick<
+  SubmittableInputProps<AutosizeInputProps>,
+  'value' | 'onSubmit' | 'onCancel'
+>;
 
 export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
   const { t } = useTranslation();
@@ -23,15 +30,18 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
   const parentRef = useRef<HTMLDivElement>(null);
   const [parentRect] = useRect(parentRef);
 
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
   const inputValidator = useInputValidator(ValidationTarget.FOLDER);
 
-  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
-    const validationResult = inputValidator(e.target.value);
-    setValidationResult(validationResult ?? undefined);
-  }, [inputValidator]);
+  const changeHandler = useCallback(
+    async (e: ChangeEvent<HTMLInputElement>) => {
+      const validationResult = inputValidator(e.target.value);
+      setValidationResult(validationResult ?? undefined);
+    },
+    [inputValidator],
+  );
   const changeHandlerDebounced = debounce(300, changeHandler);
 
   const cancelHandler = useCallback(() => {
@@ -41,9 +51,14 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
 
   const isInvalid = validationResult != null;
 
-  const maxWidth = parentRect != null
-    ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'md', validationResult != null ? false : undefined)
-    : undefined;
+  const maxWidth =
+    parentRect != null
+      ? getAdjustedMaxWidthForAutosizeInput(
+          parentRect.width,
+          'md',
+          validationResult != null ? false : undefined,
+        )
+      : undefined;
 
   return (
     <div ref={parentRef}>
@@ -52,17 +67,22 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputStyle={{ maxWidth }}
         placeholder={t('bookmark_folder.input_placeholder')}
-        aria-describedby={isInvalid ? 'bookmark-folder-name-input-feedback' : undefined}
+        aria-describedby={
+          isInvalid ? 'bookmark-folder-name-input-feedback' : undefined
+        }
         autoFocus
         onChange={changeHandlerDebounced}
         onSubmit={onSubmit}
         onCancel={cancelHandler}
       />
-      { isInvalid && (
-        <div id="bookmark-folder-name-input-feedback" className="invalid-feedback d-block my-1">
+      {isInvalid && (
+        <div
+          id="bookmark-folder-name-input-feedback"
+          className="invalid-feedback d-block my-1"
+        >
           {validationResult.message}
         </div>
-      ) }
+      )}
     </div>
   );
 };

+ 66 - 33
apps/app/src/client/components/Bookmarks/BookmarkFolderTree.tsx

@@ -1,9 +1,8 @@
-
-import React, { useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback } from 'react';
+import { useRouter } from 'next/router';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 
@@ -13,7 +12,8 @@ import { useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageData } from '~/states/page';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import {
-  useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
+  useSWRMUTxCurrentUserBookmarks,
+  useSWRxUserBookmarks,
 } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { mutateAllPageInfo, useSWRMUTxPageInfo } from '~/stores/page';
@@ -30,10 +30,10 @@ import styles from './BookmarkFolderTree.module.scss';
 //  } & IPageHasId
 
 type Props = {
-  isUserHomepage?: boolean,
-  userId?: string,
-  isOperable: boolean,
-}
+  isUserHomepage?: boolean;
+  userId?: string;
+  isOperable: boolean;
+};
 
 export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const { isUserHomepage, userId } = props;
@@ -44,10 +44,15 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   const isReadOnlyUser = useIsReadOnlyUser();
   const currentPage = useCurrentPageData();
-  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
-  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId ?? null);
-  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(currentPage?._id ?? null);
-  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } =
+    useSWRxBookmarkFolderAndChild(userId);
+  const { data: userBookmarks, mutate: mutateUserBookmarks } =
+    useSWRxUserBookmarks(userId ?? null);
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(
+    currentPage?._id ?? null,
+  );
+  const { trigger: mutateCurrentUserBookmarks } =
+    useSWRMUTxCurrentUserBookmarks();
   const { open: openDeleteModal } = usePageDeleteModalActions();
 
   const bookmarkFolderTreeMutation = useCallback(() => {
@@ -55,20 +60,43 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
     mutateCurrentUserBookmarks();
     mutatePageInfo();
     mutateBookmarkFolders();
-  }, [mutateBookmarkFolders, mutatePageInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
-
-  const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
-    const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') return;
-      toastSuccess(isCompletely ? t('deleted_pages_completely', { path: pathOrPathsToDelete }) : t('deleted_pages', { path: pathOrPathsToDelete }));
-      bookmarkFolderTreeMutation();
-      mutateAllPageInfo();
-      if (pageToDelete.data._id === currentPage?._id && _isRecursively) {
-        router.push(`/trash${currentPage.path}`);
-      }
-    };
-    openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
-  }, [openDeleteModal, t, bookmarkFolderTreeMutation, currentPage?._id, currentPage?.path, router]);
+  }, [
+    mutateBookmarkFolders,
+    mutatePageInfo,
+    mutateCurrentUserBookmarks,
+    mutateUserBookmarks,
+  ]);
+
+  const onClickDeleteMenuItemHandler = useCallback(
+    (pageToDelete: IPageToDeleteWithMeta) => {
+      const pageDeletedHandler: OnDeletedFunction = (
+        pathOrPathsToDelete,
+        _isRecursively,
+        isCompletely,
+      ) => {
+        if (typeof pathOrPathsToDelete !== 'string') return;
+        toastSuccess(
+          isCompletely
+            ? t('deleted_pages_completely', { path: pathOrPathsToDelete })
+            : t('deleted_pages', { path: pathOrPathsToDelete }),
+        );
+        bookmarkFolderTreeMutation();
+        mutateAllPageInfo();
+        if (pageToDelete.data._id === currentPage?._id && _isRecursively) {
+          router.push(`/trash${currentPage.path}`);
+        }
+      };
+      openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
+    },
+    [
+      openDeleteModal,
+      t,
+      bookmarkFolderTreeMutation,
+      currentPage?._id,
+      currentPage?.path,
+      router,
+    ],
+  );
 
   /* TODO: update in bookmarks folder v2. */
   // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
@@ -106,9 +134,12 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   return (
     <DndProvider backend={HTML5Backend}>
-
-      <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`}>
-        <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group py-2`}>
+      <div
+        className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`}
+      >
+        <ul
+          className={`grw-foldertree ${styles['grw-foldertree']} list-group py-2`}
+        >
           {bookmarkFolders?.map((bookmarkFolder) => {
             return (
               <BookmarkFolderItem
@@ -125,8 +156,11 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               />
             );
           })}
-          {userBookmarks?.map(userBookmark => (
-            <div key={userBookmark?._id} className="grw-foldertree-item-container grw-root-bookmarks">
+          {userBookmarks?.map((userBookmark) => (
+            <div
+              key={userBookmark?._id}
+              className="grw-foldertree-item-container grw-root-bookmarks"
+            >
               <BookmarkItem
                 isReadOnlyUser={!!isReadOnlyUser}
                 isOperable={props.isOperable}
@@ -156,7 +190,6 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
           </DragAndDropWrapper>
         )} */}
       </div>
-
     </DndProvider>
   );
 };

+ 173 - 114
apps/app/src/client/components/Bookmarks/BookmarkItem.tsx

@@ -1,44 +1,48 @@
-import React, {
-  useCallback, useMemo, useState, type JSX,
-} from 'react';
-
-import nodePath from 'path';
-
-import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
+import { useRouter } from 'next/router';
+import type {
+  IPageHasId,
+  IPageInfoExt,
+  IPageToDeleteWithMeta,
+} from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
-import { useRouter } from 'next/router';
+import nodePath from 'path';
 import { useTranslation } from 'react-i18next';
-import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
-
+import { DropdownToggle, UncontrolledTooltip } from 'reactstrap';
 
 import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info';
+import type {
+  BookmarkFolderItems,
+  DragItemDataType,
+} from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { useFetchCurrentPage } from '~/states/page';
 import { usePutBackPageModalActions } from '~/states/ui/modal/put-back-page';
 import { mutateAllPageInfo, useSWRxPageInfo } from '~/stores/page';
 
-import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
+import {
+  MenuItemType,
+  PageItemControl,
+} from '../Common/Dropdown/PageItemControl';
 import { PageListItemS } from '../PageList/PageListItemS';
-
 import { BookmarkItemRenameInput } from './BookmarkItemRenameInput';
 import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 type Props = {
-  isReadOnlyUser: boolean
-  isOperable: boolean,
-  bookmarkedPage: IPageHasId | null,
-  level: number,
-  parentFolder: BookmarkFolderItems | null,
-  canMoveToRoot: boolean,
-  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
-  bookmarkFolderTreeMutation: () => void,
-}
+  isReadOnlyUser: boolean;
+  isOperable: boolean;
+  bookmarkedPage: IPageHasId | null;
+  level: number;
+  parentFolder: BookmarkFolderItems | null;
+  canMoveToRoot: boolean;
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void;
+  bookmarkFolderTreeMutation: () => void;
+};
 
 export const BookmarkItem = (props: Props): JSX.Element => {
   const BASE_FOLDER_PADDING = 15;
@@ -48,46 +52,56 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const router = useRouter();
 
   const {
-    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
-    parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
+    isReadOnlyUser,
+    isOperable,
+    bookmarkedPage,
+    onClickDeleteMenuItemHandler,
+    parentFolder,
+    level,
+    canMoveToRoot,
+    bookmarkFolderTreeMutation,
   } = props;
   const { open: openPutBackPageModal } = usePutBackPageModalActions();
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
-  const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage?._id);
+  const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(
+    bookmarkedPage?._id,
+  );
   const { fetchCurrentPage } = useFetchCurrentPage();
 
-  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level));
+  const paddingLeft = BASE_BOOKMARK_PADDING + BASE_FOLDER_PADDING * level;
   const dragItem: Partial<DragItemDataType> = {
-    ...bookmarkedPage, parentFolder,
+    ...bookmarkedPage,
+    parentFolder,
   };
 
   const bookmarkedPageId = bookmarkedPage?._id;
   const bookmarkedPagePath = bookmarkedPage?.path;
   const bookmarkedPageRevision = bookmarkedPage?.revision;
 
-  const onClickMoveToRootHandler = useCallback(async() => {
+  const onClickMoveToRootHandler = useCallback(async () => {
     if (bookmarkedPageId == null) return;
 
     try {
       await addBookmarkToFolder(bookmarkedPageId, null);
       bookmarkFolderTreeMutation();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [bookmarkFolderTreeMutation, bookmarkedPageId]);
 
-  const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
-    if (shouldBookmark) {
-      await bookmark(pageId);
-    }
-    else {
-      await unbookmark(pageId);
-    }
-    bookmarkFolderTreeMutation();
-    mutatePageInfo();
-  }, [bookmarkFolderTreeMutation, mutatePageInfo]);
+  const bookmarkMenuItemClickHandler = useCallback(
+    async (pageId: string, shouldBookmark: boolean) => {
+      if (shouldBookmark) {
+        await bookmark(pageId);
+      } else {
+        await unbookmark(pageId);
+      }
+      bookmarkFolderTreeMutation();
+      mutatePageInfo();
+    },
+    [bookmarkFolderTreeMutation, mutatePageInfo],
+  );
 
   const renameMenuItemClickHandler = useCallback(() => {
     setRenameInputShown(true);
@@ -97,57 +111,81 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     setRenameInputShown(false);
   }, []);
 
-  const rename = useCallback(async(inputText: string) => {
-    if (bookmarkedPageId == null) return;
-
+  const rename = useCallback(
+    async (inputText: string) => {
+      if (bookmarkedPageId == null) return;
 
-    if (inputText.trim() === '') {
-      return cancel();
-    }
+      if (inputText.trim() === '') {
+        return cancel();
+      }
 
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPagePath ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputText.trim());
-    if (newPagePath === bookmarkedPagePath) {
-      setRenameInputShown(false);
-      return;
-    }
+      const parentPath = pathUtils.addTrailingSlash(
+        nodePath.dirname(bookmarkedPagePath ?? ''),
+      );
+      const newPagePath = nodePath.resolve(parentPath, inputText.trim());
+      if (newPagePath === bookmarkedPagePath) {
+        setRenameInputShown(false);
+        return;
+      }
 
-    try {
-      setRenameInputShown(false);
-      await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
-      bookmarkFolderTreeMutation();
-      mutatePageInfo();
-    }
-    catch (err) {
-      setRenameInputShown(true);
-      toastError(err);
-    }
-  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
+      try {
+        setRenameInputShown(false);
+        await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
+        bookmarkFolderTreeMutation();
+        mutatePageInfo();
+      } catch (err) {
+        setRenameInputShown(true);
+        toastError(err);
+      }
+    },
+    [
+      bookmarkedPageId,
+      bookmarkedPagePath,
+      bookmarkedPageRevision,
+      cancel,
+      bookmarkFolderTreeMutation,
+      mutatePageInfo,
+    ],
+  );
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
-    if (bookmarkedPageId == null) return;
+  const deleteMenuItemClickHandler = useCallback(
+    async (
+      _pageId: string,
+      pageInfo: IPageInfoExt | undefined,
+    ): Promise<void> => {
+      if (bookmarkedPageId == null) return;
 
-    if (bookmarkedPageId == null || bookmarkedPagePath == null) {
-      throw Error('_id and path must not be null.');
-    }
+      if (bookmarkedPageId == null || bookmarkedPagePath == null) {
+        throw Error('_id and path must not be null.');
+      }
 
-    const pageToDelete: IPageToDeleteWithMeta = {
-      data: {
-        _id: bookmarkedPageId,
-        revision: bookmarkedPageRevision == null ? null : getIdStringForRef(bookmarkedPageRevision),
-        path: bookmarkedPagePath,
-      },
-      meta: pageInfo,
-    };
+      const pageToDelete: IPageToDeleteWithMeta = {
+        data: {
+          _id: bookmarkedPageId,
+          revision:
+            bookmarkedPageRevision == null
+              ? null
+              : getIdStringForRef(bookmarkedPageRevision),
+          path: bookmarkedPagePath,
+        },
+        meta: pageInfo,
+      };
 
-    onClickDeleteMenuItemHandler(pageToDelete);
-  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, onClickDeleteMenuItemHandler]);
+      onClickDeleteMenuItemHandler(pageToDelete);
+    },
+    [
+      bookmarkedPageId,
+      bookmarkedPagePath,
+      bookmarkedPageRevision,
+      onClickDeleteMenuItemHandler,
+    ],
+  );
 
   const putBackClickHandler = useCallback(() => {
     if (bookmarkedPage == null) return;
 
     const { _id: pageId, path } = bookmarkedPage;
-    const putBackedHandler = async() => {
+    const putBackedHandler = async () => {
       try {
         await unlink(path);
         mutateAllPageInfo();
@@ -155,36 +193,41 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         router.push(`/${pageId}`);
         fetchCurrentPage({ force: true });
         toastSuccess(t('page_has_been_reverted', { path }));
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
     };
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
-  }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, fetchCurrentPage, t]);
-
-  const {
-    pageTitle, formerPagePath, isFormerRoot, bookmarkItemId,
-  } = useMemo(() => {
-    const bookmarkItemId = `bookmark-item-${bookmarkedPageId}`;
+  }, [
+    bookmarkedPage,
+    openPutBackPageModal,
+    bookmarkFolderTreeMutation,
+    router,
+    fetchCurrentPage,
+    t,
+  ]);
+
+  const { pageTitle, formerPagePath, isFormerRoot, bookmarkItemId } =
+    useMemo(() => {
+      const bookmarkItemId = `bookmark-item-${bookmarkedPageId}`;
+
+      if (bookmarkedPagePath == null) {
+        return {
+          pageTitle: '',
+          formerPagePath: '',
+          isFormerRoot: false,
+          bookmarkItemId,
+        };
+      }
 
-    if (bookmarkedPagePath == null) {
+      const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
       return {
-        pageTitle: '',
-        formerPagePath: '',
-        isFormerRoot: false,
+        pageTitle: dPagePath.latter,
+        formerPagePath: dPagePath.former,
+        isFormerRoot: dPagePath.isFormerRoot,
         bookmarkItemId,
       };
-    }
-
-    const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
-    return {
-      pageTitle: dPagePath.latter,
-      formerPagePath: dPagePath.former,
-      isFormerRoot: dPagePath.isFormerRoot,
-      bookmarkItemId,
-    };
-  }, [bookmarkedPagePath, bookmarkedPageId]);
+    }, [bookmarkedPagePath, bookmarkedPageId]);
 
   if (bookmarkedPage == null) {
     return <></>;
@@ -202,15 +245,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         id={bookmarkItemId}
         style={{ paddingLeft }}
       >
-        { isRenameInputShown
-          ? (
-            <BookmarkItemRenameInput
-              value={nodePath.basename(bookmarkedPage.path ?? '')}
-              onSubmit={rename}
-              onCancel={() => { setRenameInputShown(false) }}
-            />
-          )
-          : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} isNarrowView />}
+        {isRenameInputShown ? (
+          <BookmarkItemRenameInput
+            value={nodePath.basename(bookmarkedPage.path ?? '')}
+            onSubmit={rename}
+            onCancel={() => {
+              setRenameInputShown(false);
+            }}
+          />
+        ) : (
+          <PageListItemS
+            page={bookmarkedPage}
+            pageTitle={pageTitle}
+            isNarrowView
+          />
+        )}
 
         <div className="grw-foldertree-control">
           <PageItemControl
@@ -224,11 +273,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickRevertMenuItem={putBackClickHandler}
-            additionalMenuItemOnTopRenderer={canMoveToRoot
-              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler} />
-              : undefined}
+            additionalMenuItemOnTopRenderer={
+              canMoveToRoot
+                ? () => (
+                    <BookmarkMoveToRootBtn
+                      pageId={bookmarkedPage._id}
+                      onClickMoveToRootHandler={onClickMoveToRootHandler}
+                    />
+                  )
+                : undefined
+            }
           >
-            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
+            <DropdownToggle
+              color="transparent"
+              className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1"
+            >
               <span className="material-symbols-outlined p-1">more_vert</span>
             </DropdownToggle>
           </PageItemControl>

+ 39 - 19
apps/app/src/client/components/Bookmarks/BookmarkItemRenameInput.tsx

@@ -1,19 +1,26 @@
 import type { ChangeEvent, JSX } from 'react';
 import { useCallback, useRef, useState } from 'react';
-
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import { debounce } from 'throttle-debounce';
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
-import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
-
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+
+import {
+  AutosizeSubmittableInput,
+  getAdjustedMaxWidthForAutosizeInput,
+} from '../Common/SubmittableInput';
 import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
 
-
-type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
+type Props = Pick<
+  SubmittableInputProps<AutosizeInputProps>,
+  'value' | 'onSubmit' | 'onCancel'
+>;
 
 export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
   const { t } = useTranslation();
@@ -23,15 +30,18 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
   const parentRef = useRef<HTMLDivElement>(null);
   const [parentRect] = useRect(parentRef);
 
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
   const inputValidator = useInputValidator(ValidationTarget.PAGE);
 
-  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
-    const validationResult = inputValidator(e.target.value);
-    setValidationResult(validationResult ?? undefined);
-  }, [inputValidator]);
+  const changeHandler = useCallback(
+    async (e: ChangeEvent<HTMLInputElement>) => {
+      const validationResult = inputValidator(e.target.value);
+      setValidationResult(validationResult ?? undefined);
+    },
+    [inputValidator],
+  );
   const changeHandlerDebounced = debounce(300, changeHandler);
 
   const cancelHandler = useCallback(() => {
@@ -41,9 +51,14 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
 
   const isInvalid = validationResult != null;
 
-  const maxWidth = parentRect != null
-    ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'md', validationResult != null ? false : undefined)
-    : undefined;
+  const maxWidth =
+    parentRect != null
+      ? getAdjustedMaxWidthForAutosizeInput(
+          parentRect.width,
+          'md',
+          validationResult != null ? false : undefined,
+        )
+      : undefined;
 
   return (
     <div className="flex-fill" ref={parentRef}>
@@ -52,17 +67,22 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputStyle={{ maxWidth }}
         placeholder={t('Input page name')}
-        aria-describedby={isInvalid ? 'bookmark-item-rename-input-feedback' : undefined}
+        aria-describedby={
+          isInvalid ? 'bookmark-item-rename-input-feedback' : undefined
+        }
         autoFocus
         onChange={changeHandlerDebounced}
         onSubmit={onSubmit}
         onCancel={cancelHandler}
       />
-      { isInvalid && (
-        <div id="bookmark-item-rename-input-feedback" className="invalid-feedback d-block my-1">
+      {isInvalid && (
+        <div
+          id="bookmark-item-rename-input-feedback"
+          className="invalid-feedback d-block my-1"
+        >
           {validationResult.message}
         </div>
-      ) }
+      )}
     </div>
   );
 };

+ 5 - 4
apps/app/src/client/components/Bookmarks/BookmarkMoveToRootBtn.tsx

@@ -1,11 +1,10 @@
 import React from 'react';
-
 import { useTranslation } from 'react-i18next';
 import { DropdownItem } from 'reactstrap';
 
 export const BookmarkMoveToRootBtn: React.FC<{
-  pageId: string
-  onClickMoveToRootHandler: (pageId: string) => Promise<void>
+  pageId: string;
+  onClickMoveToRootHandler: (pageId: string) => Promise<void>;
 }> = React.memo(({ pageId, onClickMoveToRootHandler }) => {
   const { t } = useTranslation();
 
@@ -14,7 +13,9 @@ export const BookmarkMoveToRootBtn: React.FC<{
       onClick={() => onClickMoveToRootHandler(pageId)}
       className="grw-page-control-dropdown-item"
     >
-      <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
+      <span className="material-symbols-outlined grw-page-control-dropdown-icon">
+        bookmark
+      </span>
       {t('bookmark_folder.move_to_root')}
     </DropdownItem>
   );

+ 33 - 21
apps/app/src/client/components/Bookmarks/DragAndDropWrapper.tsx

@@ -1,33 +1,44 @@
-import type { ReactNode, JSX } from 'react';
-
+import type { JSX, ReactNode } from 'react';
 import { useDrag, useDrop } from 'react-dnd';
 
 import type { DragItemDataType } from '~/interfaces/bookmark-info';
 
 type DragAndDropWrapperProps = {
-  item?: Partial<DragItemDataType>
-  type: string[]
-  children: ReactNode
-  useDragMode?: boolean
-  useDropMode?: boolean
-  onDropItem?:(item: DragItemDataType, type: string | null | symbol) => Promise<void>
-  isDropable?:(item: Partial<DragItemDataType>, type: string | null | symbol) => boolean
-}
+  item?: Partial<DragItemDataType>;
+  type: string[];
+  children: ReactNode;
+  useDragMode?: boolean;
+  useDropMode?: boolean;
+  onDropItem?: (
+    item: DragItemDataType,
+    type: string | null | symbol,
+  ) => Promise<void>;
+  isDropable?: (
+    item: Partial<DragItemDataType>,
+    type: string | null | symbol,
+  ) => boolean;
+};
 
-export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element => {
+export const DragAndDropWrapper = (
+  props: DragAndDropWrapperProps,
+): JSX.Element => {
   const {
-    item, children, useDragMode, useDropMode, type, onDropItem, isDropable,
+    item,
+    children,
+    useDragMode,
+    useDropMode,
+    type,
+    onDropItem,
+    isDropable,
   } = props;
 
-
   const acceptedTypes = type;
   const sourcetype: string | symbol = type[0];
 
-
   const [, dragRef] = useDrag({
     type: sourcetype,
     item,
-    collect: monitor => ({
+    collect: (monitor) => ({
       isDragging: monitor.isDragging(),
       canDrag: monitor.canDrag(),
     }),
@@ -48,7 +59,7 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
       }
       return false;
     },
-    collect: monitor => ({
+    collect: (monitor) => ({
       isOver: monitor.isOver({ shallow: true }) && monitor.canDrop(),
     }),
   }));
@@ -57,17 +68,18 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
     if (useDragMode && useDropMode) {
       dragRef(c);
       dropRef(c);
-    }
-    else if (useDragMode) {
+    } else if (useDragMode) {
       dragRef(c);
-    }
-    else if (useDropMode) {
+    } else if (useDropMode) {
       dropRef(c);
     }
   };
 
   return (
-    <div ref={getCallback} className={`grw-drag-drop-container ${isOver ? 'grw-accept-drop-item' : ''}`}>
+    <div
+      ref={getCallback}
+      className={`grw-drag-drop-container ${isOver ? 'grw-accept-drop-item' : ''}`}
+    >
       {children}
     </div>
   );

+ 4 - 4
apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx

@@ -1,4 +1,4 @@
-import { render, screen, fireEvent } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
 
 import { DescendantsPageListModal } from './DescendantsPageListModal';
 
@@ -33,7 +33,9 @@ vi.mock('~/states/context', () => ({
 }));
 
 vi.mock('../DescendantsPageList', () => ({
-  DescendantsPageList: () => <div data-testid="descendants-page-list">DescendantsPageList</div>,
+  DescendantsPageList: () => (
+    <div data-testid="descendants-page-list">DescendantsPageList</div>
+  ),
 }));
 
 vi.mock('../PageTimeline', () => ({
@@ -41,7 +43,6 @@ vi.mock('../PageTimeline', () => ({
 }));
 
 describe('DescendantsPageListModal.tsx', () => {
-
   it('should render the modal when isOpened is true', () => {
     render(<DescendantsPageListModal />);
     expect(screen.getByTestId('descendants-page-list-modal')).not.toBeNull();
@@ -55,7 +56,6 @@ describe('DescendantsPageListModal.tsx', () => {
   });
 
   describe('when device is larger than lg', () => {
-
     it('should render CustomNavTab', () => {
       render(<DescendantsPageListModal />);
       expect(screen.getByTestId('custom-nav-tab')).not.toBeNull();

+ 72 - 36
apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx

@@ -1,18 +1,16 @@
-
-import React, {
-  useState, useMemo, useEffect, useCallback,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
+import type React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
+import { useTranslation } from 'next-i18next';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 import { useIsSharedUser } from '~/states/context';
 import { useDeviceLargerThanLg } from '~/states/ui/device';
-import { useDescendantsPageListModalActions, useDescendantsPageListModalStatus } from '~/states/ui/modal/descendants-page-list';
+import {
+  useDescendantsPageListModalActions,
+  useDescendantsPageListModalStatus,
+} from '~/states/ui/modal/descendants-page-list';
 
 import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
@@ -21,9 +19,38 @@ import ExpandOrContractButton from '../ExpandOrContractButton';
 
 import styles from './DescendantsPageListModal.module.scss';
 
-const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('../DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
+const DescendantsPageList = dynamic<DescendantsPageListProps>(
+  () => import('../DescendantsPageList').then((mod) => mod.DescendantsPageList),
+  { ssr: false },
+);
+
+const PageTimeline = dynamic(
+  () => import('../PageTimeline').then((mod) => mod.PageTimeline),
+  { ssr: false },
+);
+
+const PageListTabIcon = (): React.JSX.Element => (
+  <span className="material-symbols-outlined">subject</span>
+);
+
+const PageListTabContent = (): React.JSX.Element => {
+  const status = useDescendantsPageListModalStatus();
+  const path = status?.path;
+
+  if (path == null) {
+    return <></>;
+  }
+
+  return <DescendantsPageList path={path} />;
+};
+
+const TimelineTabIcon = (): React.JSX.Element => (
+  <span data-testid="timeline-tab-button" className="material-symbols-outlined">
+    timeline
+  </span>
+);
 
-const PageTimeline = dynamic(() => import('../PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
+const TimelineTabContent = (): React.JSX.Element => <PageTimeline />;
 
 /**
  * DescendantsPageListModalSubstance - Presentation component (all logic here)
@@ -58,26 +85,19 @@ const DescendantsPageListModalSubstance = ({
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: () => <span className="material-symbols-outlined">subject</span>,
-        Content: () => {
-          if (path == null) {
-            return <></>;
-          }
-          return <DescendantsPageList path={path} />;
-        },
+        Icon: PageListTabIcon,
+        Content: PageListTabContent,
         i18n: t('page_list'),
         isLinkEnabled: () => !isSharedUser,
       },
       timeline: {
-        Icon: () => <span data-testid="timeline-tab-button" className="material-symbols-outlined">timeline</span>,
-        Content: () => {
-          return <PageTimeline />;
-        },
+        Icon: TimelineTabIcon,
+        Content: TimelineTabContent,
         i18n: t('Timeline View'),
         isLinkEnabled: () => !isSharedUser,
       },
     };
-  }, [isSharedUser, path, t]);
+  }, [isSharedUser, t]);
 
   // Memoize event handlers
   const expandWindow = useCallback(() => {
@@ -90,20 +110,32 @@ const DescendantsPageListModalSubstance = ({
   }, [onExpandedChange]);
   const onNavSelected = useCallback((v: string) => setActiveTab(v), []);
 
-  const buttons = useMemo(() => (
-    <span className="me-3">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={expandWindow}
-        contractWindow={contractWindow}
-      />
-      <button type="button" className="btn btn-close ms-2" onClick={closeModal} aria-label="Close"></button>
-    </span>
-  ), [closeModal, isWindowExpanded, expandWindow, contractWindow]);
+  const buttons = useMemo(
+    () => (
+      <span className="me-3">
+        <ExpandOrContractButton
+          isWindowExpanded={isWindowExpanded}
+          expandWindow={expandWindow}
+          contractWindow={contractWindow}
+        />
+        <button
+          type="button"
+          className="btn btn-close ms-2"
+          onClick={closeModal}
+          aria-label="Close"
+        ></button>
+      </span>
+    ),
+    [closeModal, isWindowExpanded, expandWindow, contractWindow],
+  );
 
   return (
     <div>
-      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={closeModal} close={buttons}>
+      <ModalHeader
+        className={isDeviceLargerThanLg ? 'p-0' : ''}
+        toggle={closeModal}
+        close={buttons}
+      >
         {isDeviceLargerThanLg && (
           <CustomNavTab
             activeTab={activeTab}
@@ -125,7 +157,11 @@ const DescendantsPageListModalSubstance = ({
         <CustomTabContent
           activeTab={activeTab}
           navTabMapping={navTabMapping}
-          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
+          additionalClassNames={
+            !isDeviceLargerThanLg
+              ? ['grw-tab-content-style-md-down']
+              : undefined
+          }
         />
       </ModalBody>
     </div>

+ 4 - 1
apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx

@@ -10,7 +10,10 @@ export const DescendantsPageListModalLazyLoaded = (): JSX.Element => {
 
   const DescendantsPageListModal = useLazyLoader<DescendantsPageListModalProps>(
     'descendants-page-list-modal',
-    () => import('./DescendantsPageListModal').then(mod => ({ default: mod.DescendantsPageListModal })),
+    () =>
+      import('./DescendantsPageListModal').then((mod) => ({
+        default: mod.DescendantsPageListModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 51 - 31
apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -1,15 +1,18 @@
-import React, {
-  useState, useEffect, useRef, type JSX,
-} from 'react';
-
+import React, { type JSX, useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useRipple } from 'react-use-ripple';
 import {
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 
 import { useGlobalSocket } from '~/states/socket-io';
-import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+import {
+  useSWRxInAppNotificationStatus,
+  useSWRxInAppNotifications,
+} from '~/stores/in-app-notification';
 
 import InAppNotificationList from './InAppNotificationList';
 
@@ -20,11 +23,14 @@ export const InAppNotificationDropdown = (): JSX.Element => {
   const limit = 6;
 
   const socket = useGlobalSocket();
-  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(
-    limit, undefined, undefined,
-    { revalidateOnFocus: isOpen },
-  );
-  const { data: inAppNotificationUnreadStatusCount, mutate: mutateInAppNotificationUnreadStatusCount } = useSWRxInAppNotificationStatus();
+  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } =
+    useSWRxInAppNotifications(limit, undefined, undefined, {
+      revalidateOnFocus: isOpen,
+    });
+  const {
+    data: inAppNotificationUnreadStatusCount,
+    mutate: mutateInAppNotificationUnreadStatusCount,
+  } = useSWRxInAppNotificationStatus();
 
   // ripple
   const buttonRef = useRef(null);
@@ -43,9 +49,12 @@ export const InAppNotificationDropdown = (): JSX.Element => {
     }
   }, [mutateInAppNotificationUnreadStatusCount, socket]);
 
-
-  const toggleDropdownHandler = async() => {
-    if (!isOpen && inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
+  const toggleDropdownHandler = async () => {
+    if (
+      !isOpen &&
+      inAppNotificationUnreadStatusCount != null &&
+      inAppNotificationUnreadStatusCount > 0
+    ) {
       mutateInAppNotificationUnreadStatusCount();
     }
 
@@ -56,34 +65,45 @@ export const InAppNotificationDropdown = (): JSX.Element => {
     setIsOpen(newIsOpenState);
   };
 
-  let badge;
-  if (inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
-    badge = <span className="badge rounded-pill bg-danger grw-notification-badge">{inAppNotificationUnreadStatusCount}</span>;
-  }
-  else {
-    badge = '';
-  }
+  const badge =
+    inAppNotificationUnreadStatusCount != null &&
+    inAppNotificationUnreadStatusCount > 0 ? (
+      <span className="badge rounded-pill bg-danger grw-notification-badge">
+        {inAppNotificationUnreadStatusCount}
+      </span>
+    ) : null;
 
   return (
-    <Dropdown className="notification-wrapper grw-notification-dropdown" isOpen={isOpen} toggle={toggleDropdownHandler} direction="end">
+    <Dropdown
+      className="notification-wrapper grw-notification-dropdown"
+      isOpen={isOpen}
+      toggle={toggleDropdownHandler}
+      direction="end"
+    >
       <DropdownToggle className="px-3" color="primary" innerRef={buttonRef}>
         <span className="material-symbols-outlined">notifications</span> {badge}
       </DropdownToggle>
 
-      { isOpen && (
+      {isOpen && (
         <DropdownMenu end>
-          { inAppNotificationData != null && inAppNotificationData.docs.length === 0
-          // no items
-            ? <DropdownItem disabled>{t('in_app_notification.no_unread_messages')}</DropdownItem>
-          // render DropdownItem
-            : <InAppNotificationList inAppNotificationData={inAppNotificationData} />
-          }
+          {inAppNotificationData != null &&
+          inAppNotificationData.docs.length === 0 ? (
+            // no items
+            <DropdownItem disabled>
+              {t('in_app_notification.no_unread_messages')}
+            </DropdownItem>
+          ) : (
+            // render DropdownItem
+            <InAppNotificationList
+              inAppNotificationData={inAppNotificationData}
+            />
+          )}
           <DropdownItem divider />
           <DropdownItem tag="a" href="/me/all-in-app-notifications">
-            { t('in_app_notification.see_all') }
+            {t('in_app_notification.see_all')}
           </DropdownItem>
         </DropdownMenu>
-      ) }
+      )}
     </Dropdown>
   );
 };

+ 14 - 12
apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx

@@ -1,6 +1,5 @@
 import type { FC, JSX } from 'react';
 import React from 'react';
-
 import type { HasObjectId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 
@@ -12,12 +11,11 @@ import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 import { useModelNotification } from './ModelNotification';
 
 interface Props {
-  notification: IInAppNotification & HasObjectId
-  onUnopenedNotificationOpend?: () => void,
+  notification: IInAppNotification & HasObjectId;
+  onUnopenedNotificationOpend?: () => void;
 }
 
 const InAppNotificationElm: FC<Props> = (props: Props) => {
-
   const { notification, onUnopenedNotificationOpend } = props;
 
   const modelNotificationUtils = useModelNotification(notification);
@@ -32,7 +30,9 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
     return <></>;
   }
 
-  const clickHandler = async(notification: IInAppNotification & HasObjectId): Promise<void> => {
+  const clickHandler = async (
+    notification: IInAppNotification & HasObjectId,
+  ): Promise<void> => {
     if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
       // set notification status "OPEND"
       await apiv3Post('/in-app-notification/open', { id: notification._id });
@@ -65,24 +65,26 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
   };
 
   return (
-    <div className="list-group-item list-group-item-action" style={{ cursor: 'pointer' }}>
+    <div
+      className="list-group-item list-group-item-action"
+      style={{ cursor: 'pointer' }}
+    >
       <a
         href={isDisabled ? undefined : clickLink}
         onClick={() => clickHandler(notification)}
       >
         <div className="d-flex align-items-center">
           <span
-            className={`${notification.status === InAppNotificationStatuses.STATUS_UNOPENED
-              ? 'grw-unopend-notification'
-              : 'ms-2'
+            className={`${
+              notification.status === InAppNotificationStatuses.STATUS_UNOPENED
+                ? 'grw-unopend-notification'
+                : 'ms-2'
             } rounded-circle me-3`}
-          >
-          </span>
+          ></span>
 
           {renderActionUserPictures()}
 
           <Notification />
-
         </div>
       </a>
     </div>

+ 8 - 9
apps/app/src/client/components/InAppNotification/InAppNotificationList.tsx

@@ -1,18 +1,18 @@
 import type { FC } from 'react';
 import React from 'react';
-
 import type { HasObjectId } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
-import type { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
-
+import type {
+  IInAppNotification,
+  PaginateResult,
+} from '~/interfaces/in-app-notification';
 
 import InAppNotificationElm from './InAppNotificationElm';
 
-
 type Props = {
-  inAppNotificationData?: PaginateResult<IInAppNotification>,
-  onUnopenedNotificationOpend?: () => void,
+  inAppNotificationData?: PaginateResult<IInAppNotification>;
+  onUnopenedNotificationOpend?: () => void;
 };
 
 const InAppNotificationList: FC<Props> = (props: Props) => {
@@ -32,7 +32,7 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
 
   return (
     <div className="list-group">
-      { notifications.map((notification: IInAppNotification & HasObjectId) => {
+      {notifications.map((notification: IInAppNotification & HasObjectId) => {
         return (
           <InAppNotificationElm
             key={notification._id}
@@ -40,10 +40,9 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
             onUnopenedNotificationOpend={onUnopenedNotificationOpend}
           />
         );
-      }) }
+      })}
     </div>
   );
 };
 
-
 export default InAppNotificationList;

+ 105 - 80
apps/app/src/client/components/InAppNotification/InAppNotificationPage.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import React, { useState } from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
@@ -8,117 +7,143 @@ import { useTranslation } from 'next-i18next';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { showPageLimitationXLAtom } from '~/states/server-configurations';
-import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+import {
+  useSWRxInAppNotificationStatus,
+  useSWRxInAppNotifications,
+} from '~/stores/in-app-notification';
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import PaginationWrapper from '../PaginationWrapper';
-
 import InAppNotificationList from './InAppNotificationList';
 
-export const InAppNotificationPage: FC = () => {
+type InAppNotificationCategoryByStatusProps = {
+  status?: InAppNotificationStatuses;
+};
+
+const EmptyIcon: FC = () => {
+  return null;
+};
+
+const InAppNotificationCategoryByStatus: FC<
+  InAppNotificationCategoryByStatusProps
+> = ({ status }) => {
   const { t } = useTranslation('commons');
 
   const showPageLimitationXL = useAtomValue(showPageLimitationXLAtom);
-
   const limit = showPageLimitationXL != null ? showPageLimitationXL : 20;
 
-  const InAppNotificationCategoryByStatus = (status?: InAppNotificationStatuses) => {
-    const [activePage, setActivePage] = useState(1);
-    const offset = (activePage - 1) * limit;
-
-    let categoryStatus;
+  const [activePage, setActivePage] = useState(1);
+  const offset = (activePage - 1) * limit;
 
-    switch (status) {
-      case InAppNotificationStatuses.STATUS_UNOPENED:
-        categoryStatus = InAppNotificationStatuses.STATUS_UNOPENED;
-        break;
-      default:
-    }
+  const categoryStatus =
+    status === InAppNotificationStatuses.STATUS_UNOPENED
+      ? InAppNotificationStatuses.STATUS_UNOPENED
+      : undefined;
 
-    const { data: notificationData, mutate: mutateNotificationData } = useSWRxInAppNotifications(limit, offset, categoryStatus);
-    const { mutate: mutateAllNotificationData } = useSWRxInAppNotifications(limit, offset, undefined);
-    const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
+  const { data: notificationData, mutate: mutateNotificationData } =
+    useSWRxInAppNotifications(limit, offset, categoryStatus);
+  const { mutate: mutateAllNotificationData } = useSWRxInAppNotifications(
+    limit,
+    offset,
+    undefined,
+  );
+  const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
 
-    const setAllNotificationPageNumber = (selectedPageNumber): void => {
-      setActivePage(selectedPageNumber);
-    };
+  const setAllNotificationPageNumber = (selectedPageNumber: number): void => {
+    setActivePage(selectedPageNumber);
+  };
 
+  if (notificationData == null) {
+    return (
+      <div className="wiki" data-testid="grw-in-app-notification-page-spinner">
+        <div className="text-muted text-center">
+          <LoadingSpinner className="me-1 fs-3" />
+        </div>
+      </div>
+    );
+  }
+
+  const updateUnopendNotificationStatusesToOpened = async () => {
+    await apiv3Put('/in-app-notification/all-statuses-open');
+    // mutate notification statuses in 'UNREAD' Category
+    mutateNotificationData();
+    // mutate notification statuses in 'ALL' Category
+    mutateAllNotificationData();
+    mutateNotificationCount();
+  };
 
-    if (notificationData == null) {
-      return (
-        <div className="wiki" data-testid="grw-in-app-notification-page-spinner">
-          <div className="text-muted text-center">
-            <LoadingSpinner className="me-1 fs-3" />
+  return (
+    <>
+      {status === InAppNotificationStatuses.STATUS_UNOPENED &&
+        notificationData.totalDocs > 0 && (
+          <div className="mb-2 d-flex justify-content-end">
+            <button
+              type="button"
+              className="btn btn-outline-primary"
+              onClick={updateUnopendNotificationStatusesToOpened}
+            >
+              {t('in_app_notification.mark_all_as_read')}
+            </button>
           </div>
+        )}
+      {notificationData != null && notificationData.docs.length === 0 ? (
+        // no items
+        t('in_app_notification.no_unread_messages')
+      ) : (
+        // render list-group
+        <InAppNotificationList inAppNotificationData={notificationData} />
+      )}
+
+      {notificationData.totalDocs > 0 && (
+        <div className="mt-4">
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={setAllNotificationPageNumber}
+            totalItemsCount={notificationData.totalDocs}
+            pagingLimit={notificationData.limit}
+            align="center"
+            size="sm"
+          />
         </div>
-      );
-    }
+      )}
+    </>
+  );
+};
 
-    const updateUnopendNotificationStatusesToOpened = async() => {
-      await apiv3Put('/in-app-notification/all-statuses-open');
-      // mutate notification statuses in 'UNREAD' Category
-      mutateNotificationData();
-      // mutate notification statuses in 'ALL' Category
-      mutateAllNotificationData();
-      mutateNotificationCount();
-    };
+const InAppNotificationAllTabContent: FC = () => {
+  return <InAppNotificationCategoryByStatus />;
+};
 
+const InAppNotificationUnreadTabContent: FC = () => {
+  return (
+    <InAppNotificationCategoryByStatus
+      status={InAppNotificationStatuses.STATUS_UNOPENED}
+    />
+  );
+};
 
-    return (
-      <>
-        {(status === InAppNotificationStatuses.STATUS_UNOPENED && notificationData.totalDocs > 0)
-      && (
-        <div className="mb-2 d-flex justify-content-end">
-          <button
-            type="button"
-            className="btn btn-outline-primary"
-            onClick={updateUnopendNotificationStatusesToOpened}
-          >
-            {t('in_app_notification.mark_all_as_read')}
-          </button>
-        </div>
-      )}
-        { notificationData != null && notificationData.docs.length === 0
-          // no items
-          ? t('in_app_notification.no_unread_messages')
-          // render list-group
-          : (
-            <InAppNotificationList inAppNotificationData={notificationData} />
-          )
-        }
-
-        {notificationData.totalDocs > 0 && (
-          <div className="mt-4">
-            <PaginationWrapper
-              activePage={activePage}
-              changePage={setAllNotificationPageNumber}
-              totalItemsCount={notificationData.totalDocs}
-              pagingLimit={notificationData.limit}
-              align="center"
-              size="sm"
-            />
-          </div>
-        ) }
-      </>
-    );
-  };
+export const InAppNotificationPage: FC = () => {
+  const { t } = useTranslation('commons');
 
   const navTabMapping = {
     user_infomation: {
-      Icon: () => <></>,
-      Content: () => InAppNotificationCategoryByStatus(),
+      Icon: EmptyIcon,
+      Content: InAppNotificationAllTabContent,
       i18n: t('in_app_notification.all'),
     },
     external_accounts: {
-      Icon: () => <></>,
-      Content: () => InAppNotificationCategoryByStatus(InAppNotificationStatuses.STATUS_UNOPENED),
+      Icon: EmptyIcon,
+      Content: InAppNotificationUnreadTabContent,
       i18n: t('in_app_notification.unopend'),
     },
   };
 
   return (
     <div data-testid="grw-in-app-notification-page">
-      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['mt-4']} />
+      <CustomNavAndContents
+        navTabMapping={navTabMapping}
+        tabContentClasses={['mt-4']}
+      />
     </div>
   );
 };

+ 7 - 9
apps/app/src/client/components/InAppNotification/ModelNotification/ModelNotification.tsx

@@ -1,6 +1,5 @@
 import type { FC, JSX } from 'react';
 import React from 'react';
-
 import type { HasObjectId } from '@growi/core';
 import { PagePathLabel } from '@growi/ui/dist/components';
 
@@ -11,12 +10,12 @@ import FormattedDistanceDate from '../../FormattedDistanceDate';
 import styles from './ModelNotification.module.scss';
 
 type Props = {
-  notification: IInAppNotification & HasObjectId
-  actionMsg: string
-  actionIcon: string
-  actionUsers: string
-  hideActionUsers?: boolean
-  subMsg?: JSX.Element
+  notification: IInAppNotification & HasObjectId;
+  actionMsg: string;
+  actionIcon: string;
+  actionUsers: string;
+  hideActionUsers?: boolean;
+  subMsg?: JSX.Element;
 };
 
 export const ModelNotification: FC<Props> = ({
@@ -27,7 +26,6 @@ export const ModelNotification: FC<Props> = ({
   hideActionUsers = false,
   subMsg,
 }: Props) => {
-
   return (
     <div className={`${styles['modal-notification']} p-2 overflow-hidden`}>
       <div className="text-truncate page-title">
@@ -35,7 +33,7 @@ export const ModelNotification: FC<Props> = ({
         {` ${actionMsg}`}
         <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
       </div>
-      { subMsg }
+      {subMsg}
       <span className="material-symbols-outlined me-2">{actionIcon}</span>
       <FormattedDistanceDate
         id={notification._id}

+ 42 - 20
apps/app/src/client/components/InAppNotification/ModelNotification/PageBulkExportJobModelNotification.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
-
-import { isPopulated, type HasObjectId } from '@growi/core';
+import { type HasObjectId, isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
 import type { IPageBulkExportJobHasId } from '~/features/page-bulk-export/interfaces/page-bulk-export';
@@ -8,21 +7,25 @@ import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import * as pageBulkExportJobSerializers from '~/models/serializers/in-app-notification-snapshot/page-bulk-export-job';
 
+import type { ModelNotificationUtils } from '.';
 import { ModelNotification } from './ModelNotification';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
-import type { ModelNotificationUtils } from '.';
-
-
-export const usePageBulkExportJobModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
-
+export const usePageBulkExportJobModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ModelNotificationUtils | null => {
   const { t } = useTranslation();
-  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+  const { actionMsg, actionIcon } =
+    useActionMsgAndIconForModelNotification(notification);
 
   const isPageBulkExportJobModelNotification = (
-      notification: IInAppNotification & HasObjectId,
-  ): notification is IInAppNotification<IPageBulkExportJobHasId> & HasObjectId => {
-    return notification.targetModel === SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB;
+    notification: IInAppNotification & HasObjectId,
+  ): notification is IInAppNotification<IPageBulkExportJobHasId> &
+    HasObjectId => {
+    return (
+      notification.targetModel ===
+      SupportedTargetModel.MODEL_PAGE_BULK_EXPORT_JOB
+    );
   };
 
   if (!isPageBulkExportJobModelNotification(notification)) {
@@ -31,14 +34,31 @@ export const usePageBulkExportJobModelNotification = (notification: IInAppNotifi
 
   const actionUsers = notification.user.username;
 
-  notification.parsedSnapshot = pageBulkExportJobSerializers.parseSnapshot(notification.snapshot);
+  notification.parsedSnapshot = pageBulkExportJobSerializers.parseSnapshot(
+    notification.snapshot,
+  );
 
   const getSubMsg = (): JSX.Element => {
-    if (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED && notification.target == null) {
-      return <div className="text-danger"><small>{t('page_export.bulk_export_download_expired')}</small></div>;
+    if (
+      notification.action ===
+        SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED &&
+      notification.target == null
+    ) {
+      return (
+        <div className="text-danger">
+          <small>{t('page_export.bulk_export_download_expired')}</small>
+        </div>
+      );
     }
-    if (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED) {
-      return <div className="text-danger"><small>{t('page_export.bulk_export_job_expired')}</small></div>;
+    if (
+      notification.action ===
+      SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED
+    ) {
+      return (
+        <div className="text-danger">
+          <small>{t('page_export.bulk_export_job_expired')}</small>
+        </div>
+      );
     }
     return <></>;
   };
@@ -56,14 +76,16 @@ export const usePageBulkExportJobModelNotification = (notification: IInAppNotifi
     );
   };
 
-  const clickLink = (notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED
-    && notification.target?.attachment != null && isPopulated(notification.target?.attachment))
-    ? notification.target.attachment.downloadPathProxied : undefined;
+  const clickLink =
+    notification.action === SupportedAction.ACTION_PAGE_BULK_EXPORT_COMPLETED &&
+    notification.target?.attachment != null &&
+    isPopulated(notification.target?.attachment)
+      ? notification.target.attachment.downloadPathProxied
+      : undefined;
 
   return {
     Notification,
     clickLink,
     isDisabled: notification.target == null,
   };
-
 };

+ 15 - 14
apps/app/src/client/components/InAppNotification/ModelNotification/PageModelNotification.tsx

@@ -1,21 +1,21 @@
 import React, { useCallback } from 'react';
-
-import type { IPage, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
+import type { HasObjectId, IPage } from '@growi/core';
 
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 import * as pageSerializers from '~/models/serializers/in-app-notification-snapshot/page';
 
+import type { ModelNotificationUtils } from '.';
 import { ModelNotification } from './ModelNotification';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
-import type { ModelNotificationUtils } from '.';
-
-export const usePageModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
-
+export const usePageModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ModelNotificationUtils | null => {
   const router = useRouter();
-  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+  const { actionMsg, actionIcon } =
+    useActionMsgAndIconForModelNotification(notification);
 
   const getActionUsers = useCallback(() => {
     const latestActionUsers = notification.actionUsers.slice(0, 3);
@@ -27,18 +27,18 @@ export const usePageModelNotification = (notification: IInAppNotification & HasO
     const latestUsersCount = latestUsers.length;
     if (latestUsersCount === 1) {
       actionedUsers = latestUsers[0];
-    }
-    else if (notification.actionUsers.length >= 4) {
+    } else if (notification.actionUsers.length >= 4) {
       actionedUsers = `${latestUsers.slice(0, 2).join(', ')} and ${notification.actionUsers.length - 2} others`;
-    }
-    else {
+    } else {
       actionedUsers = latestUsers.join(', ');
     }
 
     return actionedUsers;
   }, [notification.actionUsers]);
 
-  const isPageModelNotification = (notification: IInAppNotification & HasObjectId): notification is IInAppNotification<IPage> & HasObjectId => {
+  const isPageModelNotification = (
+    notification: IInAppNotification & HasObjectId,
+  ): notification is IInAppNotification<IPage> & HasObjectId => {
     return notification.targetModel === SupportedTargetModel.MODEL_PAGE;
   };
 
@@ -48,7 +48,9 @@ export const usePageModelNotification = (notification: IInAppNotification & HasO
 
   const actionUsers = getActionUsers();
 
-  notification.parsedSnapshot = pageSerializers.parseSnapshot(notification.snapshot);
+  notification.parsedSnapshot = pageSerializers.parseSnapshot(
+    notification.snapshot,
+  );
 
   const Notification = () => {
     return (
@@ -75,5 +77,4 @@ export const usePageModelNotification = (notification: IInAppNotification & HasO
     Notification,
     publishOpen,
   };
-
 };

+ 10 - 10
apps/app/src/client/components/InAppNotification/ModelNotification/UserModelNotification.tsx

@@ -1,23 +1,24 @@
 import React from 'react';
-
-import type { IUser, HasObjectId } from '@growi/core';
 import { useRouter } from 'next/router';
+import type { HasObjectId, IUser } from '@growi/core';
 
 import { SupportedTargetModel } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
+import type { ModelNotificationUtils } from '.';
 import { ModelNotification } from './ModelNotification';
 import { useActionMsgAndIconForModelNotification } from './useActionAndMsg';
 
-import type { ModelNotificationUtils } from '.';
-
-
-export const useUserModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
-
-  const { actionMsg, actionIcon } = useActionMsgAndIconForModelNotification(notification);
+export const useUserModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ModelNotificationUtils | null => {
+  const { actionMsg, actionIcon } =
+    useActionMsgAndIconForModelNotification(notification);
   const router = useRouter();
 
-  const isUserModelNotification = (notification: IInAppNotification & HasObjectId): notification is IInAppNotification<IUser> & HasObjectId => {
+  const isUserModelNotification = (
+    notification: IInAppNotification & HasObjectId,
+  ): notification is IInAppNotification<IUser> & HasObjectId => {
     return notification.targetModel === SupportedTargetModel.MODEL_USER;
   };
 
@@ -46,5 +47,4 @@ export const useUserModelNotification = (notification: IInAppNotification & HasO
     Notification,
     publishOpen,
   };
-
 };

+ 13 - 11
apps/app/src/client/components/InAppNotification/ModelNotification/index.tsx

@@ -1,31 +1,33 @@
 import type { FC } from 'react';
-
 import type { HasObjectId } from '@growi/core';
 
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
-
 import { usePageBulkExportJobModelNotification } from './PageBulkExportJobModelNotification';
 import { usePageModelNotification } from './PageModelNotification';
 import { useUserModelNotification } from './UserModelNotification';
 
 export interface ModelNotificationUtils {
-  Notification: FC
-  publishOpen?: () => void
-  clickLink?: string
+  Notification: FC;
+  publishOpen?: () => void;
+  clickLink?: string;
   // Whether actions from clicking notification is disabled or not.
   // User can still open the notification when true.
-  isDisabled?: boolean
+  isDisabled?: boolean;
 }
 
-export const useModelNotification = (notification: IInAppNotification & HasObjectId): ModelNotificationUtils | null => {
-
+export const useModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ModelNotificationUtils | null => {
   const pageModelNotificationUtils = usePageModelNotification(notification);
   const userModelNotificationUtils = useUserModelNotification(notification);
-  const pageBulkExportResultModelNotificationUtils = usePageBulkExportJobModelNotification(notification);
-
-  const modelNotificationUtils = pageModelNotificationUtils ?? userModelNotificationUtils ?? pageBulkExportResultModelNotificationUtils;
+  const pageBulkExportResultModelNotificationUtils =
+    usePageBulkExportJobModelNotification(notification);
 
+  const modelNotificationUtils =
+    pageModelNotificationUtils ??
+    userModelNotificationUtils ??
+    pageBulkExportResultModelNotificationUtils;
 
   return modelNotificationUtils;
 };

+ 6 - 4
apps/app/src/client/components/InAppNotification/ModelNotification/useActionAndMsg.ts

@@ -4,11 +4,13 @@ import { SupportedAction } from '~/interfaces/activity';
 import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 export type ActionMsgAndIconType = {
-  actionMsg: string
-  actionIcon: string
-}
+  actionMsg: string;
+  actionIcon: string;
+};
 
-export const useActionMsgAndIconForModelNotification = (notification: IInAppNotification & HasObjectId): ActionMsgAndIconType => {
+export const useActionMsgAndIconForModelNotification = (
+  notification: IInAppNotification & HasObjectId,
+): ActionMsgAndIconType => {
   const actionType: string = notification.action;
   let actionMsg: string;
   let actionIcon: string;

+ 0 - 2
apps/app/src/client/components/ItemsTree/ItemsTreeContentSkeleton.tsx

@@ -4,9 +4,7 @@ import { Skeleton } from '~/client/components/Skeleton';
 
 import styles from './ItemsTreeContentSkeleton.module.scss';
 
-
 const ItemsTreeContentSkeleton = (): JSX.Element => {
-
   return (
     <ul className="list-group py-3">
       <Skeleton additionalClass={`${styles['text-skeleton-level1']} pe-3`} />

+ 21 - 9
apps/app/src/client/components/LoginForm/ExternalAuthButton.tsx

@@ -1,14 +1,21 @@
-import { useCallback, type JSX } from 'react';
-
+import { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 
 const authIcon = {
-  [IExternalAuthProviderType.google]: <span className="growi-custom-icons align-bottom">google</span>,
-  [IExternalAuthProviderType.github]: <span className="growi-custom-icons align-bottom">github</span>,
-  [IExternalAuthProviderType.oidc]: <span className="growi-custom-icons align-bottom">openid</span>,
-  [IExternalAuthProviderType.saml]: <span className="material-symbols-outlined align-bottom">key</span>,
+  [IExternalAuthProviderType.google]: (
+    <span className="growi-custom-icons align-bottom">google</span>
+  ),
+  [IExternalAuthProviderType.github]: (
+    <span className="growi-custom-icons align-bottom">github</span>
+  ),
+  [IExternalAuthProviderType.oidc]: (
+    <span className="growi-custom-icons align-bottom">openid</span>
+  ),
+  [IExternalAuthProviderType.saml]: (
+    <span className="material-symbols-outlined align-bottom">key</span>
+  ),
 };
 
 const authLabel = {
@@ -18,8 +25,11 @@ const authLabel = {
   [IExternalAuthProviderType.saml]: 'SAML',
 };
 
-
-export const ExternalAuthButton = ({ authType }: {authType: IExternalAuthProviderType}): JSX.Element => {
+export const ExternalAuthButton = ({
+  authType,
+}: {
+  authType: IExternalAuthProviderType;
+}): JSX.Element => {
   const { t } = useTranslation();
 
   const key = `btn-auth-${authType.toString()}`;
@@ -37,7 +47,9 @@ export const ExternalAuthButton = ({ authType }: {authType: IExternalAuthProvide
       onClick={handleLoginWithExternalAuth}
     >
       <span>{authIcon[authType]}</span>
-      <span className="flex-grow-1">{t('Sign in with External auth', { signin: authLabel[authType] })}</span>
+      <span className="flex-grow-1">
+        {t('Sign in with External auth', { signin: authLabel[authType] })}
+      </span>
     </button>
   );
 };

+ 34 - 20
apps/app/src/client/components/LoginForm/LoginForm.spec.tsx

@@ -1,11 +1,6 @@
 import React from 'react';
-
-import {
-  render, screen, fireEvent, waitFor,
-} from '@testing-library/react';
-import {
-  describe, it, expect, vi, beforeEach,
-} from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import type { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
@@ -89,7 +84,9 @@ describe('LoginForm - Error Display', () => {
 
       render(<LoginForm {...props} />);
 
-      expect(screen.getByText('jwks must be a JSON Web Key Set formatted object')).toBeInTheDocument();
+      expect(
+        screen.getByText('jwks must be a JSON Web Key Set formatted object'),
+      ).toBeInTheDocument();
     });
   });
 
@@ -110,7 +107,9 @@ describe('LoginForm - Error Display', () => {
 
       render(<LoginForm {...props} />);
 
-      expect(screen.getByText('jwks must be a JSON Web Key Set formatted object')).toBeInTheDocument();
+      expect(
+        screen.getByText('jwks must be a JSON Web Key Set formatted object'),
+      ).toBeInTheDocument();
     });
 
     it('should not render local/LDAP form but should still show errors', () => {
@@ -129,9 +128,15 @@ describe('LoginForm - Error Display', () => {
 
       render(<LoginForm {...props} />);
 
-      expect(screen.queryByTestId('tiUsernameForLogin')).not.toBeInTheDocument();
-      expect(screen.queryByTestId('tiPasswordForLogin')).not.toBeInTheDocument();
-      expect(screen.getByText('OIDC authentication failed')).toBeInTheDocument();
+      expect(
+        screen.queryByTestId('tiUsernameForLogin'),
+      ).not.toBeInTheDocument();
+      expect(
+        screen.queryByTestId('tiPasswordForLogin'),
+      ).not.toBeInTheDocument();
+      expect(
+        screen.getByText('OIDC authentication failed'),
+      ).toBeInTheDocument();
     });
   });
 
@@ -153,7 +158,7 @@ describe('LoginForm - Error Display', () => {
       expect(screen.getByText('External error message')).toBeInTheDocument();
     });
 
-    it('should prioritize login errors over external account login errors after failed login', async() => {
+    it('should prioritize login errors over external account login errors after failed login', async () => {
       const externalAccountLoginError = {
         message: 'External error message',
         name: 'ExternalAccountLoginError',
@@ -190,18 +195,23 @@ describe('LoginForm - Error Display', () => {
 
       // Wait for login error to appear and external error to be replaced
       await waitFor(() => {
-        expect(screen.getByText('Invalid username or password')).toBeInTheDocument();
+        expect(
+          screen.getByText('Invalid username or password'),
+        ).toBeInTheDocument();
       });
 
       // External error should no longer be visible when login error exists
-      expect(screen.queryByText('External error message')).not.toBeInTheDocument();
+      expect(
+        screen.queryByText('External error message'),
+      ).not.toBeInTheDocument();
     });
 
-    it('should display dangerouslySetInnerHTML errors for PROVIDER_DUPLICATED_USERNAME_EXCEPTION', async() => {
+    it('should display dangerouslySetInnerHTML errors for PROVIDER_DUPLICATED_USERNAME_EXCEPTION', async () => {
       // Mock API call to return PROVIDER_DUPLICATED_USERNAME_EXCEPTION error
       mockApiv3Post.mockRejectedValueOnce([
         {
-          message: 'This username is already taken by <a href="/login">another provider</a>',
+          message:
+            'This username is already taken by <a href="/login">another provider</a>',
           code: 'provider-duplicated-username-exception',
           args: {},
         },
@@ -226,11 +236,13 @@ describe('LoginForm - Error Display', () => {
       // Wait for the dangerouslySetInnerHTML error to appear
       await waitFor(() => {
         // Check that the error with HTML content is rendered
-        expect(screen.getByText(/This username is already taken by/)).toBeInTheDocument();
+        expect(
+          screen.getByText(/This username is already taken by/),
+        ).toBeInTheDocument();
       });
     });
 
-    it('should handle multiple login errors correctly', async() => {
+    it('should handle multiple login errors correctly', async () => {
       // Mock API call to return multiple errors
       mockApiv3Post.mockRejectedValueOnce([
         {
@@ -281,7 +293,9 @@ describe('LoginForm - Error Display', () => {
 
       render(<LoginForm {...props} />);
 
-      expect(screen.getByText('Authentication service unavailable')).toBeInTheDocument();
+      expect(
+        screen.getByText('Authentication service unavailable'),
+      ).toBeInTheDocument();
     });
   });
 });

+ 299 - 183
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -1,10 +1,7 @@
-import React, {
-  useState, useEffect, useCallback, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
+import { useRouter } from 'next/router';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
@@ -17,43 +14,50 @@ import { RegistrationMode } from '~/interfaces/registration-mode';
 import { toArrayIfNot } from '~/utils/array-utils';
 
 import { CompleteUserRegistration } from '../CompleteUserRegistration';
-
 import { ExternalAuthButton } from './ExternalAuthButton';
 
 import styles from './LoginForm.module.scss';
 
-
 const moduleClass = styles['login-form'];
 
-
 type LoginFormProps = {
-  username?: string,
-  name?: string,
-  email?: string,
-  isEmailAuthenticationEnabled: boolean,
-  registrationMode: RegistrationMode,
-  registrationWhitelist: string[],
-  isPasswordResetEnabled: boolean,
-  isLocalStrategySetup: boolean,
-  isLdapStrategySetup: boolean,
-  isLdapSetupFailed: boolean,
-  enabledExternalAuthType?: IExternalAuthProviderType[],
-  isMailerSetup?: boolean,
-  externalAccountLoginError?: IExternalAccountLoginError,
-  minPasswordLength: number,
-}
+  username?: string;
+  name?: string;
+  email?: string;
+  isEmailAuthenticationEnabled: boolean;
+  registrationMode: RegistrationMode;
+  registrationWhitelist: string[];
+  isPasswordResetEnabled: boolean;
+  isLocalStrategySetup: boolean;
+  isLdapStrategySetup: boolean;
+  isLdapSetupFailed: boolean;
+  enabledExternalAuthType?: IExternalAuthProviderType[];
+  isMailerSetup?: boolean;
+  externalAccountLoginError?: IExternalAccountLoginError;
+  minPasswordLength: number;
+};
 export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const { t } = useTranslation();
 
   const router = useRouter();
 
   const {
-    isLocalStrategySetup, isLdapStrategySetup, isLdapSetupFailed, isPasswordResetEnabled,
-    isEmailAuthenticationEnabled, registrationMode, registrationWhitelist, isMailerSetup, enabledExternalAuthType, minPasswordLength,
+    isLocalStrategySetup,
+    isLdapStrategySetup,
+    isLdapSetupFailed,
+    isPasswordResetEnabled,
+    isEmailAuthenticationEnabled,
+    registrationMode,
+    registrationWhitelist,
+    isMailerSetup,
+    enabledExternalAuthType,
+    minPasswordLength,
   } = props;
 
-  const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
-  const isSomeExternalAuthEnabled = enabledExternalAuthType != null && enabledExternalAuthType.length > 0;
+  const isLocalOrLdapStrategiesEnabled =
+    isLocalStrategySetup || isLdapStrategySetup;
+  const isSomeExternalAuthEnabled =
+    enabledExternalAuthType != null && enabledExternalAuthType.length > 0;
 
   // states
   const [isRegistering, setIsRegistering] = useState(false);
@@ -69,11 +73,13 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   const [passwordForRegister, setPasswordForRegister] = useState('');
   const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
   // For UserActivation
-  const [emailForRegistrationOrder, setEmailForRegistrationOrder] = useState('');
+  const [emailForRegistrationOrder, setEmailForRegistrationOrder] =
+    useState('');
 
   const [isSuccessToRagistration, setIsSuccessToRagistration] = useState(false);
 
-  const isRegistrationEnabled = isLocalStrategySetup && registrationMode !== RegistrationMode.CLOSED;
+  const isRegistrationEnabled =
+    isLocalStrategySetup && registrationMode !== RegistrationMode.CLOSED;
 
   const tWithOpt = useTWithOpt();
 
@@ -89,34 +95,35 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     setLoginErrors([]);
   }, [loginErrors.length]);
 
-  const handleLoginWithLocalSubmit = useCallback(async(e) => {
-    e.preventDefault();
-    resetLoginErrors();
-    setIsLoading(true);
-
-    const loginForm = {
-      username: usernameForLogin,
-      password: passwordForLogin,
-    };
+  const handleLoginWithLocalSubmit = useCallback(
+    async (e) => {
+      e.preventDefault();
+      resetLoginErrors();
+      setIsLoading(true);
 
-    try {
-      const res = await apiv3Post('/login', { loginForm });
-      const { redirectTo } = res.data;
+      const loginForm = {
+        username: usernameForLogin,
+        password: passwordForLogin,
+      };
 
-      if (redirectTo != null) {
-        return router.push(redirectTo);
-      }
+      try {
+        const res = await apiv3Post('/login', { loginForm });
+        const { redirectTo } = res.data;
 
-      return router.push('/');
-    }
-    catch (err) {
-      const errs = toArrayIfNot(err);
-      setLoginErrors(errs);
-      setIsLoading(false);
-    }
-    return;
+        if (redirectTo != null) {
+          return router.push(redirectTo);
+        }
 
-  }, [passwordForLogin, resetLoginErrors, router, usernameForLogin]);
+        return router.push('/');
+      } catch (err) {
+        const errs = toArrayIfNot(err);
+        setLoginErrors(errs);
+        setIsLoading(false);
+      }
+      return;
+    },
+    [passwordForLogin, resetLoginErrors, router, usernameForLogin],
+  );
 
   // separate errors based on error code
   const separateErrorsBasedOnErrorCode = useCallback((errors: IErrorV3[]) => {
@@ -126,8 +133,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     errors.forEach((err) => {
       if (err.code === LoginErrorCode.PROVIDER_DUPLICATED_USERNAME_EXCEPTION) {
         loginErrorListForDangerouslySetInnerHTML.push(err);
-      }
-      else {
+      } else {
         loginErrorList.push(err);
       }
     });
@@ -136,31 +142,48 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
   }, []);
 
   // wrap error elements which use dangerouslySetInnerHtml
-  const generateDangerouslySetErrors = useCallback((errors: IErrorV3[]): JSX.Element => {
-    if (errors == null || errors.length === 0) return <></>;
-    return (
-      <div className="alert alert-danger">
-        {errors.map((err) => {
-          // eslint-disable-next-line react/no-danger
-          return <small dangerouslySetInnerHTML={{ __html: tWithOpt(err.message, err.args) }}></small>;
-        })}
-      </div>
-    );
-  }, [tWithOpt]);
+  const generateDangerouslySetErrors = useCallback(
+    (errors: IErrorV3[]): JSX.Element => {
+      if (errors == null || errors.length === 0) return <></>;
+      return (
+        <div className="alert alert-danger">
+          {errors.map((err, index) => {
+            // eslint-disable-next-line react/no-danger
+            return (
+              <small
+                key={`${err.code}-${index}`}
+                // biome-ignore lint/security/noDangerouslySetInnerHtml: rendered HTML from translations
+                dangerouslySetInnerHTML={{
+                  __html: tWithOpt(err.message, err.args),
+                }}
+              ></small>
+            );
+          })}
+        </div>
+      );
+    },
+    [tWithOpt],
+  );
 
   // wrap error elements which do not use dangerouslySetInnerHtml
-  const generateSafelySetErrors = useCallback((errors: (IErrorV3 | IExternalAccountLoginError)[]): JSX.Element => {
-    if (errors == null || errors.length === 0) return <></>;
-    return (
-      <ul className="alert alert-danger">
-        {errors.map((err, index) => (
-          <small className={index > 0 ? 'mt-1' : ''}>
-            {tWithOpt(err.message, err.args)}
-          </small>
-        ))}
-      </ul>
-    );
-  }, [tWithOpt]);
+  const generateSafelySetErrors = useCallback(
+    (errors: (IErrorV3 | IExternalAccountLoginError)[]): JSX.Element => {
+      if (errors == null || errors.length === 0) return <></>;
+      return (
+        <ul className="alert alert-danger">
+          {errors.map((err, index) => (
+            <small
+              key={`${err.message}-${index}`}
+              className={index > 0 ? 'mt-1' : ''}
+            >
+              {tWithOpt(err.message, err.args)}
+            </small>
+          ))}
+        </ul>
+      );
+    },
+    [tWithOpt],
+  );
 
   const renderLocalOrLdapLoginForm = useCallback(() => {
     const { isLdapStrategySetup } = props;
@@ -175,16 +198,30 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         {/* !! - END OF HIDDEN ELEMENT - !! */}
         {isLdapSetupFailed && (
           <div className="alert alert-warning small">
-            <strong><span className="material-symbols-outlined">info</span>{t('login.enabled_ldap_has_configuration_problem')}</strong><br />
+            <strong>
+              <span className="material-symbols-outlined">info</span>
+              {t('login.enabled_ldap_has_configuration_problem')}
+            </strong>
+            <br />
             {/* eslint-disable-next-line react/no-danger */}
-            <span dangerouslySetInnerHTML={{ __html: t('login.set_env_var_for_logs') }}></span>
+            <span
+              // biome-ignore lint/security/noDangerouslySetInnerHtml: rendered HTML from translations
+              dangerouslySetInnerHTML={{
+                __html: t('login.set_env_var_for_logs'),
+              }}
+            ></span>
           </div>
         )}
 
-        <form role="form" onSubmit={handleLoginWithLocalSubmit} id="login-form">
+        <form onSubmit={handleLoginWithLocalSubmit} id="login-form">
           <div className="input-group">
-            <label className="text-white opacity-75 d-flex align-items-center" htmlFor="tiUsernameForLogin">
-              <span className="material-symbols-outlined" aria-label="Username or E-mail">person</span>
+            <label
+              className="text-white opacity-75 d-flex align-items-center"
+              htmlFor="tiUsernameForLogin"
+            >
+              <span className="material-symbols-outlined" aria-hidden="true">
+                person
+              </span>
             </label>
             <input
               id="tiUsernameForLogin"
@@ -192,7 +229,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
               className={`form-control rounded ms-2 ${isLdapStrategySetup ? 'ldap-space' : ''}`}
               data-testid="tiUsernameForLogin"
               placeholder="Username or E-mail"
-              onChange={(e) => { setUsernameForLogin(e.target.value) }}
+              onChange={(e) => {
+                setUsernameForLogin(e.target.value);
+              }}
               name="usernameForLogin"
             />
             {isLdapStrategySetup && (
@@ -201,12 +240,16 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 <span className="">LDAP</span>
               </small>
             )}
-
           </div>
 
           <div className="input-group">
-            <label className="text-white opacity-75 d-flex align-items-center" htmlFor="tiPasswordForLogin">
-              <span className="material-symbols-outlined" aria-label="Password">lock</span>
+            <label
+              className="text-white opacity-75 d-flex align-items-center"
+              htmlFor="tiPasswordForLogin"
+            >
+              <span className="material-symbols-outlined" aria-hidden="true">
+                lock
+              </span>
             </label>
             <input
               id="tiPasswordForLogin"
@@ -214,7 +257,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
               className="form-control rounded ms-2"
               data-testid="tiPasswordForLogin"
               placeholder="Password"
-              onChange={(e) => { setPasswordForLogin(e.target.value) }}
+              onChange={(e) => {
+                setPasswordForLogin(e.target.value);
+              }}
               name="passwordForLogin"
             />
           </div>
@@ -230,7 +275,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 {isLoading ? (
                   <LoadingSpinner />
                 ) : (
-                  <span className="material-symbols-outlined" aria-label="Login">login</span>
+                  <span
+                    className="material-symbols-outlined"
+                    aria-hidden="true"
+                  >
+                    login
+                  </span>
                 )}
               </span>
               <span className="flex-grow-1">{t('Sign in')}</span>
@@ -239,10 +289,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         </form>
       </>
     );
-  }, [
-    props, isLdapSetupFailed, t, handleLoginWithLocalSubmit, isLoading,
-  ]);
-
+  }, [props, isLdapSetupFailed, t, handleLoginWithLocalSubmit, isLoading]);
 
   const renderExternalAuthLoginForm = useCallback(() => {
     const { enabledExternalAuthType } = props;
@@ -254,7 +301,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     return (
       <>
         <div className="mt-2">
-          {enabledExternalAuthType.map(authType => <ExternalAuthButton authType={authType} />)}
+          {enabledExternalAuthType.map((authType) => (
+            <ExternalAuthButton key={authType} authType={authType} />
+          ))}
         </div>
       </>
     );
@@ -265,45 +314,55 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     setRegisterErrors([]);
   }, [registerErrors.length]);
 
-  const handleRegisterFormSubmit = useCallback(async(e, requestPath) => {
-    e.preventDefault();
-    setEmailForRegistrationOrder('');
-    setIsSuccessToRagistration(false);
-    setIsLoading(true);
-
-    const registerForm = {
-      username: usernameForRegister,
-      name: nameForRegister,
-      email: emailForRegister,
-      password: passwordForRegister,
-    };
-    try {
-      const res = await apiv3Post(requestPath, { registerForm });
-
-      setIsSuccessToRagistration(true);
-      resetRegisterErrors();
-      setIsLoading(false);
-
-      const { redirectTo } = res.data;
-
-      if (redirectTo != null) {
-        router.push(redirectTo);
-      }
+  const handleRegisterFormSubmit = useCallback(
+    async (e, requestPath) => {
+      e.preventDefault();
+      setEmailForRegistrationOrder('');
+      setIsSuccessToRagistration(false);
+      setIsLoading(true);
+
+      const registerForm = {
+        username: usernameForRegister,
+        name: nameForRegister,
+        email: emailForRegister,
+        password: passwordForRegister,
+      };
+      try {
+        const res = await apiv3Post(requestPath, { registerForm });
+
+        setIsSuccessToRagistration(true);
+        resetRegisterErrors();
+        setIsLoading(false);
+
+        const { redirectTo } = res.data;
+
+        if (redirectTo != null) {
+          router.push(redirectTo);
+        }
 
-      if (isEmailAuthenticationEnabled) {
-        setEmailForRegistrationOrder(emailForRegister);
-        return;
-      }
-    }
-    catch (err) {
-      // Execute if error exists
-      if (err != null || err.length > 0) {
-        setRegisterErrors(err);
+        if (isEmailAuthenticationEnabled) {
+          setEmailForRegistrationOrder(emailForRegister);
+          return;
+        }
+      } catch (err) {
+        // Execute if error exists
+        if (err != null || err.length > 0) {
+          setRegisterErrors(err);
+        }
+        setIsLoading(false);
       }
-      setIsLoading(false);
-    }
-    return;
-  }, [usernameForRegister, nameForRegister, emailForRegister, passwordForRegister, resetRegisterErrors, router, isEmailAuthenticationEnabled]);
+      return;
+    },
+    [
+      usernameForRegister,
+      nameForRegister,
+      emailForRegister,
+      passwordForRegister,
+      resetRegisterErrors,
+      router,
+      isEmailAuthenticationEnabled,
+    ],
+  );
 
   const switchForm = useCallback(() => {
     setIsRegistering(!isRegistering);
@@ -329,34 +388,37 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             {t('page_register.notice.restricted_defail')}
           </p>
         )}
-        {(!isMailerSetup && isEmailAuthenticationEnabled) && (
+        {!isMailerSetup && isEmailAuthenticationEnabled && (
           <p className="alert alert-danger">
             <span>{t('commons:alert.please_enable_mailer')}</span>
           </p>
         )}
 
-        {
-          registerErrors != null && registerErrors.length > 0 && (
-            <p className="alert alert-danger">
-              {registerErrors.map(err => (
-                <span>
-                  {tWithOpt(err.message, err.args)}<br />
-                </span>
-              ))}
-            </p>
-          )
-        }
-
-        {
-          (isEmailAuthenticationEnabled && isSuccessToRagistration) && (
-            <p className="alert alert-success">
-              <span>{t('message.successfully_send_email_auth', { email: emailForRegistrationOrder })}</span>
-            </p>
-          )
-        }
+        {registerErrors != null && registerErrors.length > 0 && (
+          <p className="alert alert-danger">
+            {registerErrors.map((err, index) => (
+              <span key={`${err.message}-${index}`}>
+                {tWithOpt(err.message, err.args)}
+                <br />
+              </span>
+            ))}
+          </p>
+        )}
 
-        <form role="form" onSubmit={e => handleRegisterFormSubmit(e, registerAction)} id="register-form">
+        {isEmailAuthenticationEnabled && isSuccessToRagistration && (
+          <p className="alert alert-success">
+            <span>
+              {t('message.successfully_send_email_auth', {
+                email: emailForRegistrationOrder,
+              })}
+            </span>
+          </p>
+        )}
 
+        <form
+          onSubmit={(e) => handleRegisterFormSubmit(e, registerAction)}
+          id="register-form"
+        >
           {!isEmailAuthenticationEnabled && (
             <div>
               <div className="input-group" id="input-group-username">
@@ -367,7 +429,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 <input
                   type="text"
                   className="form-control rounded ms-2"
-                  onChange={(e) => { setUsernameForRegister(e.target.value) }}
+                  onChange={(e) => {
+                    setUsernameForRegister(e.target.value);
+                  }}
                   placeholder={t('User ID')}
                   name="username"
                   defaultValue={props.username}
@@ -385,7 +449,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 <input
                   type="text"
                   className="form-control rounded ms-2"
-                  onChange={(e) => { setNameForRegister(e.target.value) }}
+                  onChange={(e) => {
+                    setNameForRegister(e.target.value);
+                  }}
                   placeholder={t('Name')}
                   name="name"
                   defaultValue={props.name}
@@ -404,7 +470,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
               type="email"
               disabled={!isMailerSetup && isEmailAuthenticationEnabled}
               className="form-control rounded ms-2"
-              onChange={(e) => { setEmailForRegister(e.target.value) }}
+              onChange={(e) => {
+                setEmailForRegister(e.target.value);
+              }}
               placeholder={t('Email')}
               name="email"
               defaultValue={props.email}
@@ -437,7 +505,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 <input
                   type="password"
                   className="form-control rounded ms-2"
-                  onChange={(e) => { setPasswordForRegister(e.target.value) }}
+                  onChange={(e) => {
+                    setPasswordForRegister(e.target.value);
+                  }}
                   placeholder={t('Password')}
                   name="password"
                   required
@@ -452,7 +522,9 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
             <button
               type="submit"
               className="btn btn-secondary btn-register d-flex col-7"
-              disabled={(!isMailerSetup && isEmailAuthenticationEnabled) || isLoading}
+              disabled={
+                (!isMailerSetup && isEmailAuthenticationEnabled) || isLoading
+              }
             >
               <span>
                 {isLoading ? (
@@ -468,45 +540,82 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
         <div className="row">
           <div className="text-end col-12 mb-5">
-            <a
-              href="#login"
+            <button
+              type="button"
               className="btn btn-sm btn-secondary btn-function col-10 col-sm-9 mx-auto py-1 d-flex"
               style={{ pointerEvents: isLoading ? 'none' : undefined }}
               onClick={switchForm}
             >
               <span className="material-symbols-outlined fs-5">login</span>
               <span className="flex-grow-1">{t('Sign in is here')}</span>
-            </a>
+            </button>
           </div>
         </div>
       </React.Fragment>
     );
   }, [
-    t, isEmailAuthenticationEnabled, registrationMode, isMailerSetup, registerErrors, isSuccessToRagistration, emailForRegistrationOrder,
-    props.username, props.name, props.email, registrationWhitelist, minPasswordLength, isLoading, switchForm, tWithOpt, handleRegisterFormSubmit,
+    t,
+    isEmailAuthenticationEnabled,
+    registrationMode,
+    isMailerSetup,
+    registerErrors,
+    isSuccessToRagistration,
+    emailForRegistrationOrder,
+    props.username,
+    props.name,
+    props.email,
+    registrationWhitelist,
+    minPasswordLength,
+    isLoading,
+    switchForm,
+    tWithOpt,
+    handleRegisterFormSubmit,
   ]);
 
-  if (registrationMode === RegistrationMode.RESTRICTED && isSuccessToRagistration && !isEmailAuthenticationEnabled) {
+  if (
+    registrationMode === RegistrationMode.RESTRICTED &&
+    isSuccessToRagistration &&
+    !isEmailAuthenticationEnabled
+  ) {
     return <CompleteUserRegistration />;
   }
 
   return (
     <div className={moduleClass}>
-      <div className="nologin-dialog mx-auto rounded-4 rounded-top-0" id="nologin-dialog" data-testid="login-form">
+      <div
+        className="nologin-dialog mx-auto rounded-4 rounded-top-0"
+        id="nologin-dialog"
+        data-testid="login-form"
+      >
         <div className="row mx-0">
           <div className="col-12 px-md-4 pb-5">
-            <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
+            <ReactCardFlip
+              isFlipped={isRegistering}
+              flipDirection="horizontal"
+              cardZIndex="3"
+            >
               <div className="front">
                 {/* Error display section - always shown regardless of login method configuration */}
                 {(() => {
                   // separate login errors into two arrays based on error code
-                  const [loginErrorListForDangerouslySetInnerHTML, loginErrorList] = separateErrorsBasedOnErrorCode(loginErrors);
+                  const [
+                    loginErrorListForDangerouslySetInnerHTML,
+                    loginErrorList,
+                  ] = separateErrorsBasedOnErrorCode(loginErrors);
                   // Generate login error elements using dangerouslySetInnerHTML
-                  const loginErrorElementWithDangerouslySetInnerHTML = generateDangerouslySetErrors(loginErrorListForDangerouslySetInnerHTML);
+                  const loginErrorElementWithDangerouslySetInnerHTML =
+                    generateDangerouslySetErrors(
+                      loginErrorListForDangerouslySetInnerHTML,
+                    );
                   // Generate login error elements - prioritize loginErrorList, fallback to externalAccountLoginError
-                  const loginErrorElement = (loginErrorList ?? []).length > 0
-                    ? generateSafelySetErrors(loginErrorList)
-                    : generateSafelySetErrors(props.externalAccountLoginError != null ? [props.externalAccountLoginError] : []);
+                  const loginErrorElement =
+                    (loginErrorList ?? []).length > 0
+                      ? generateSafelySetErrors(loginErrorList)
+                      : generateSafelySetErrors(
+                          props.externalAccountLoginError != null
+                            ? [props.externalAccountLoginError]
+                            : [],
+                        );
 
                   return (
                     <>
@@ -517,11 +626,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 })()}
 
                 {isLocalOrLdapStrategiesEnabled && renderLocalOrLdapLoginForm()}
-                {isLocalOrLdapStrategiesEnabled && isSomeExternalAuthEnabled && (
-                  <div className="text-center text-line d-flex align-items-center mb-3">
-                    <p className="text-white mb-0">{t('or')}</p>
-                  </div>
-                )}
+                {isLocalOrLdapStrategiesEnabled &&
+                  isSomeExternalAuthEnabled && (
+                    <div className="text-center text-line d-flex align-items-center mb-3">
+                      <p className="text-white mb-0">{t('or')}</p>
+                    </div>
+                  )}
                 {isSomeExternalAuthEnabled && renderExternalAuthLoginForm()}
                 {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
                   <div className="mt-4">
@@ -531,22 +641,28 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                       style={{ pointerEvents: isLoading ? 'none' : 'auto' }}
                     >
                       <span className="material-symbols-outlined">vpn_key</span>
-                      <span className="flex-grow-1">{t('forgot_password.forgot_password')}</span>
+                      <span className="flex-grow-1">
+                        {t('forgot_password.forgot_password')}
+                      </span>
                     </a>
                   </div>
                 )}
                 {/* Sign up link */}
                 {isRegistrationEnabled && (
                   <div className="mt-2">
-                    <a
-                      href="#register"
+                    <button
+                      type="button"
                       className="btn btn-sm btn-secondary btn-function col-10 col-sm-9 mx-auto py-1 d-flex"
                       style={{ pointerEvents: isLoading ? 'none' : 'auto' }}
                       onClick={switchForm}
                     >
-                      <span className="material-symbols-outlined">person_add</span>
-                      <span className="flex-grow-1">{t('Sign up is here')}</span>
-                    </a>
+                      <span className="material-symbols-outlined">
+                        person_add
+                      </span>
+                      <span className="flex-grow-1">
+                        {t('Sign up is here')}
+                      </span>
+                    </button>
                   </div>
                 )}
               </div>
@@ -558,10 +674,10 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
         </div>
         <a href="https://growi.org" className="link-growi-org ps-3">
-          <span className="growi">GROWI</span><span className="org">.org</span>
+          <span className="growi">GROWI</span>
+          <span className="org">.org</span>
         </a>
       </div>
     </div>
   );
-
 };

+ 130 - 112
apps/app/src/client/components/Me/AccessTokenForm.tsx

@@ -1,5 +1,4 @@
 import React from 'react';
-
 import type { Scope } from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
 import { useForm } from 'react-hook-form';
@@ -12,132 +11,151 @@ const MAX_DESCRIPTION_LENGTH = 200;
 
 type AccessTokenFormProps = {
   submitHandler: (info: IAccessTokenInfo) => Promise<void>;
-}
+};
 
 type FormInputs = {
   expiredAt: string;
   description: string;
   scopes: Scope[];
-}
-
-export const AccessTokenForm = React.memo((props: AccessTokenFormProps): JSX.Element => {
-  const { submitHandler } = props;
-  const { t } = useTranslation();
-
-  const defaultExpiredAt = new Date();
-  defaultExpiredAt.setMonth(defaultExpiredAt.getMonth() + 1);
-  const defaultExpiredAtStr = defaultExpiredAt.toISOString().split('T')[0];
-  const todayStr = new Date().toISOString().split('T')[0];
-
-  const {
-    register,
-    handleSubmit,
-    formState: { errors, isValid },
-    watch,
-  } = useForm<FormInputs>({
-    defaultValues: {
-      expiredAt: defaultExpiredAtStr,
-      description: '',
-      scopes: [],
-    },
-  });
-
-  const onSubmit = (data: FormInputs) => {
-    const expiredAtDate = new Date(data.expiredAt);
-    expiredAtDate.setHours(23, 59, 59, 999);
-    const scopes: Scope[] = data.scopes ? data.scopes : [];
-
-    submitHandler({
-      expiredAt: expiredAtDate,
-      description: data.description,
-      scopes,
+};
+
+export const AccessTokenForm = React.memo(
+  (props: AccessTokenFormProps): JSX.Element => {
+    const { submitHandler } = props;
+    const { t } = useTranslation();
+
+    const defaultExpiredAt = new Date();
+    defaultExpiredAt.setMonth(defaultExpiredAt.getMonth() + 1);
+    const defaultExpiredAtStr = defaultExpiredAt.toISOString().split('T')[0];
+    const todayStr = new Date().toISOString().split('T')[0];
+
+    const {
+      register,
+      handleSubmit,
+      formState: { errors, isValid },
+      watch,
+    } = useForm<FormInputs>({
+      defaultValues: {
+        expiredAt: defaultExpiredAtStr,
+        description: '',
+        scopes: [],
+      },
     });
-  };
 
-  return (
-    <div className="card mt-3 mb-4">
-      <div className="card-header">{t('page_me_access_token.form.title')}</div>
-      <div className="card-body">
-        <form onSubmit={handleSubmit(onSubmit)}>
-          <div className="mb-3">
-            <label htmlFor="expiredAt" className="form-label">{t('page_me_access_token.expiredAt')}</label>
-            <div className="row">
-              <div className="col-16 col-sm-4 col-md-4 col-lg-3">
-                <div className="input-group">
-                  <input
-                    type="date"
-                    className={`form-control ${errors.expiredAt ? 'is-invalid' : ''}`}
-                    data-testid="grw-accesstoken-input-expiredAt"
-                    min={todayStr}
-                    {...register('expiredAt', {
-                      required: t('input_validation.message.required', { param: t('page_me_access_token.expiredAt') }),
-                    })}
-                  />
-                </div>
-                {errors.expiredAt && (
-                  <div className="invalid-feedback d-block">
-                    {errors.expiredAt.message}
+    const onSubmit = (data: FormInputs) => {
+      const expiredAtDate = new Date(data.expiredAt);
+      expiredAtDate.setHours(23, 59, 59, 999);
+      const scopes: Scope[] = data.scopes ? data.scopes : [];
+
+      submitHandler({
+        expiredAt: expiredAtDate,
+        description: data.description,
+        scopes,
+      });
+    };
+
+    return (
+      <div className="card mt-3 mb-4">
+        <div className="card-header">
+          {t('page_me_access_token.form.title')}
+        </div>
+        <div className="card-body">
+          <form onSubmit={handleSubmit(onSubmit)}>
+            <div className="mb-3">
+              <label htmlFor="expiredAt" className="form-label">
+                {t('page_me_access_token.expiredAt')}
+              </label>
+              <div className="row">
+                <div className="col-16 col-sm-4 col-md-4 col-lg-3">
+                  <div className="input-group">
+                    <input
+                      type="date"
+                      className={`form-control ${errors.expiredAt ? 'is-invalid' : ''}`}
+                      data-testid="grw-accesstoken-input-expiredAt"
+                      min={todayStr}
+                      {...register('expiredAt', {
+                        required: t('input_validation.message.required', {
+                          param: t('page_me_access_token.expiredAt'),
+                        }),
+                      })}
+                    />
                   </div>
-                )}
+                  {errors.expiredAt && (
+                    <div className="invalid-feedback d-block">
+                      {errors.expiredAt.message}
+                    </div>
+                  )}
+                </div>
+              </div>
+              <div className="form-text">
+                {t('page_me_access_token.form.expiredAt_desc')}
               </div>
             </div>
-            <div className="form-text">{t('page_me_access_token.form.expiredAt_desc')}</div>
-          </div>
 
-          <div className="mb-3">
-            <label htmlFor="description" className="form-label">{t('page_me_access_token.description')}</label>
-            <textarea
-              className={`form-control ${errors.description ? 'is-invalid' : ''}`}
-              rows={3}
-              data-testid="grw-accesstoken-textarea-description"
-              {...register('description', {
-                required: t('input_validation.message.required', { param: t('page_me_access_token.description') }),
-                maxLength: {
-                  value: MAX_DESCRIPTION_LENGTH,
-                  message: t('page_me_access_token.form.description_max_length', { length: MAX_DESCRIPTION_LENGTH }),
-                },
-              })}
-            />
-            {errors.description && (
-              <div className="invalid-feedback">
-                {errors.description.message}
+            <div className="mb-3">
+              <label htmlFor="description" className="form-label">
+                {t('page_me_access_token.description')}
+              </label>
+              <textarea
+                className={`form-control ${errors.description ? 'is-invalid' : ''}`}
+                rows={3}
+                data-testid="grw-accesstoken-textarea-description"
+                {...register('description', {
+                  required: t('input_validation.message.required', {
+                    param: t('page_me_access_token.description'),
+                  }),
+                  maxLength: {
+                    value: MAX_DESCRIPTION_LENGTH,
+                    message: t(
+                      'page_me_access_token.form.description_max_length',
+                      { length: MAX_DESCRIPTION_LENGTH },
+                    ),
+                  },
+                })}
+              />
+              {errors.description && (
+                <div className="invalid-feedback">
+                  {errors.description.message}
+                </div>
+              )}
+              <div className="form-text">
+                {t('page_me_access_token.form.description_desc')}
               </div>
-            )}
-            <div className="form-text">{t('page_me_access_token.form.description_desc')}</div>
-          </div>
+            </div>
 
-          <div className="mb-3">
-            <label htmlFor="scopes" className="form-label">
-              {t('page_me_access_token.scope')}
-            </label>
-            <AccessTokenScopeSelect
-              selectedScopes={watch('scopes')}
-              register={register('scopes', {
-                required: t('input_validation.message.required', { param: t('page_me_access_token.scope') }),
-              })}
-            />
-            {errors.scopes && (
-              <div className="invalid-feedback">
-                {errors.scopes.message}
+            <div className="mb-3">
+              <label htmlFor="scopes" className="form-label">
+                {t('page_me_access_token.scope')}
+              </label>
+              <AccessTokenScopeSelect
+                selectedScopes={watch('scopes')}
+                register={register('scopes', {
+                  required: t('input_validation.message.required', {
+                    param: t('page_me_access_token.scope'),
+                  }),
+                })}
+              />
+              {errors.scopes && (
+                <div className="invalid-feedback">{errors.scopes.message}</div>
+              )}
+
+              <div className="form-text mb-2">
+                {t('page_me_access_token.form.scope_desc')}
               </div>
-            )}
-
-            <div className="form-text mb-2">
-              {t('page_me_access_token.form.scope_desc')}
             </div>
-          </div>
 
-          <button
-            type="submit"
-            className="btn btn-primary"
-            data-testid="grw-accesstoken-create-button"
-            disabled={!isValid}
-          >
-            {t('page_me_access_token.create_token')}
-          </button>
-        </form>
+            <button
+              type="submit"
+              className="btn btn-primary"
+              data-testid="grw-accesstoken-create-button"
+              disabled={!isValid}
+            >
+              {t('page_me_access_token.create_token')}
+            </button>
+          </form>
+        </div>
       </div>
-    </div>
-  );
-});
+    );
+  },
+);
 AccessTokenForm.displayName = 'AccessTokenForm';

+ 76 - 68
apps/app/src/client/components/Me/AccessTokenList.tsx

@@ -1,62 +1,56 @@
 import React, { useState } from 'react';
-
 import { useTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter, Button,
-} from 'reactstrap';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import type { IResGetAccessToken } from '~/interfaces/access-token';
 
 type AccessTokenListProps = {
   accessTokens: IResGetAccessToken[];
   deleteHandler?: (tokenId: string) => void;
-}
-export const AccessTokenList = React.memo((props: AccessTokenListProps): JSX.Element => {
-
+};
+export const AccessTokenList = React.memo(
+  (props: AccessTokenListProps): JSX.Element => {
+    const { t } = useTranslation();
+    const { accessTokens, deleteHandler } = props;
+    const [tokenToDelete, setTokenToDelete] = useState<string | null>(null);
 
-  const { t } = useTranslation();
-  const { accessTokens, deleteHandler } = props;
-  const [tokenToDelete, setTokenToDelete] = useState<string | null>(null);
+    const handleDeleteClick = (tokenId: string) => {
+      setTokenToDelete(tokenId);
+    };
 
-  const handleDeleteClick = (tokenId: string) => {
-    setTokenToDelete(tokenId);
-  };
+    const handleConfirmDelete = () => {
+      if (tokenToDelete != null && deleteHandler != null) {
+        deleteHandler(tokenToDelete);
+        setTokenToDelete(null);
+      }
+    };
 
-  const handleConfirmDelete = () => {
-    if (tokenToDelete != null && deleteHandler != null) {
-      deleteHandler(tokenToDelete);
+    const toggleModal = () => {
       setTokenToDelete(null);
-    }
-  };
-
-  const toggleModal = () => {
-    setTokenToDelete(null);
-  };
+    };
 
-  return (
-    <>
-      <div className="table">
-        <table className="table table-bordered">
-          <thead>
-            <tr>
-              <th>{t('page_me_access_token.description')}</th>
-              <th>{t('page_me_access_token.expiredAt')}</th>
-              <th>{t('page_me_access_token.scope')}</th>
-              <th>{t('page_me_access_token.action')}</th>
-            </tr>
-          </thead>
-          <tbody>
-            {(accessTokens.length === 0)
-              ? (
+    return (
+      <>
+        <div className="table">
+          <table className="table table-bordered">
+            <thead>
+              <tr>
+                <th>{t('page_me_access_token.description')}</th>
+                <th>{t('page_me_access_token.expiredAt')}</th>
+                <th>{t('page_me_access_token.scope')}</th>
+                <th>{t('page_me_access_token.action')}</th>
+              </tr>
+            </thead>
+            <tbody>
+              {accessTokens.length === 0 ? (
                 <tr>
                   <td colSpan={4} className="text-center">
                     {t('page_me_access_token.no_tokens_found')}
                   </td>
                 </tr>
-              )
-              : (
-                <>{
-                  accessTokens.map(token => (
+              ) : (
+                <>
+                  {accessTokens.map((token) => (
                     <tr key={token._id}>
                       <td className="text-break">{token.description}</td>
                       <td>{token.expiredAt.toString().split('T')[0]}</td>
@@ -72,34 +66,48 @@ export const AccessTokenList = React.memo((props: AccessTokenListProps): JSX.Ele
                         </button>
                       </td>
                     </tr>
-                  ))
-                }
+                  ))}
                 </>
               )}
-          </tbody>
-        </table>
-      </div>
+            </tbody>
+          </table>
+        </div>
 
-      {/* Confirmation Modal using Reactstrap */}
-      <Modal isOpen={tokenToDelete !== null} toggle={toggleModal} centered>
-        <ModalHeader tag="h4" toggle={toggleModal} className="bg-danger text-white">
-          <span className="material-symbols-outlined me-1">warning</span>
-          {t('Warning')}
-        </ModalHeader>
-        <ModalBody>
-          <p>{t('page_me_access_token.modal.message')}</p>
-          <p className="text-danger fw-bold">{t('page_me_access_token.modal.alert')}</p>
-        </ModalBody>
-        <ModalFooter>
-          <Button color="secondary" onClick={toggleModal} data-testid="grw-accesstoken-cancel-button-in-modal">
-            {t('Cancel')}
-          </Button>
-          <Button color="danger" onClick={handleConfirmDelete} data-testid="grw-accesstoken-delete-button-in-modal">
-            {t('page_me_access_token.modal.delete_token')}
-          </Button>
-        </ModalFooter>
-      </Modal>
-    </>
-  );
-});
+        {/* Confirmation Modal using Reactstrap */}
+        <Modal isOpen={tokenToDelete !== null} toggle={toggleModal} centered>
+          <ModalHeader
+            tag="h4"
+            toggle={toggleModal}
+            className="bg-danger text-white"
+          >
+            <span className="material-symbols-outlined me-1">warning</span>
+            {t('Warning')}
+          </ModalHeader>
+          <ModalBody>
+            <p>{t('page_me_access_token.modal.message')}</p>
+            <p className="text-danger fw-bold">
+              {t('page_me_access_token.modal.alert')}
+            </p>
+          </ModalBody>
+          <ModalFooter>
+            <Button
+              color="secondary"
+              onClick={toggleModal}
+              data-testid="grw-accesstoken-cancel-button-in-modal"
+            >
+              {t('Cancel')}
+            </Button>
+            <Button
+              color="danger"
+              onClick={handleConfirmDelete}
+              data-testid="grw-accesstoken-delete-button-in-modal"
+            >
+              {t('page_me_access_token.modal.delete_token')}
+            </Button>
+          </ModalFooter>
+        </Modal>
+      </>
+    );
+  },
+);
 AccessTokenList.displayName = 'AccessTokenList';

+ 16 - 8
apps/app/src/client/components/Me/AccessTokenScopeList.tsx

@@ -1,12 +1,10 @@
-import React from 'react';
-
+import type React from 'react';
 import type { Scope } from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegisterReturn } from 'react-hook-form';
 
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 
-
 import styles from './AccessTokenScopeList.module.scss';
 
 const moduleClass = styles['access-token-scope-list'] ?? '';
@@ -18,7 +16,7 @@ interface scopeObject {
 interface AccessTokenScopeListProps {
   scopeObject: scopeObject;
   register: UseFormRegisterReturn<'scopes'>;
-  disabledScopes: Set<Scope>
+  disabledScopes: Set<Scope>;
   level?: number;
 }
 
@@ -31,7 +29,6 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
   disabledScopes,
   level = 1,
 }) => {
-
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
 
   // Convert object into an array to determine "first vs. non-first" elements
@@ -49,7 +46,11 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
               {showHr && <hr className="my-1" />}
               <div className="my-1 row">
                 <div className="col-md-5 ">
-                  <label className={`form-check-label fw-bold indentation indentation-level-${level}`}>{scopeKey}</label>
+                  <span
+                    className={`form-check-label fw-bold indentation indentation-level-${level}`}
+                  >
+                    {scopeKey}
+                  </span>
                 </div>
               </div>
 
@@ -76,11 +77,18 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
                 value={scopeValue as string}
                 {...register}
               />
-              <label className="form-check-label ms-2" htmlFor={scopeValue as string}>
+              <label
+                className="form-check-label ms-2"
+                htmlFor={scopeValue as string}
+              >
                 {scopeKey}
               </label>
             </div>
-            <div className={`col form-text ${isDeviceLargerThanMd ? '' : 'text-end'}`}>{t(`accesstoken_scopes_desc.${scopeKey.replace(/:/g, '.')}`)}</div>
+            <div
+              className={`col form-text ${isDeviceLargerThanMd ? '' : 'text-end'}`}
+            >
+              {t(`accesstoken_scopes_desc.${scopeKey.replace(/:/g, '.')}`)}
+            </div>
           </div>
         );
       })}

+ 20 - 6
apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx

@@ -1,10 +1,14 @@
-import React, { useEffect, useState, useMemo } from 'react';
-
+import type React from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import type { Scope } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import type { UseFormRegisterReturn } from 'react-hook-form';
 
-import { extractScopes, getDisabledScopes, parseScopes } from '~/client/util/scope-util';
+import {
+  extractScopes,
+  getDisabledScopes,
+  parseScopes,
+} from '~/client/util/scope-util';
 import { useIsAdmin } from '~/states/context';
 
 import { AccessTokenScopeList } from './AccessTokenScopeList';
@@ -21,11 +25,17 @@ type AccessTokenScopeSelectProps = {
 /**
  * Displays a list of permissions in a recursive, nested checkbox interface.
  */
-export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({ register, selectedScopes }) => {
+export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({
+  register,
+  selectedScopes,
+}) => {
   const [disabledScopes, setDisabledScopes] = useState<Set<Scope>>(new Set());
   const isAdmin = useIsAdmin();
 
-  const ScopesMap = useMemo(() => parseScopes({ scopes: SCOPE, isAdmin }), [isAdmin]);
+  const ScopesMap = useMemo(
+    () => parseScopes({ scopes: SCOPE, isAdmin }),
+    [isAdmin],
+  );
   const extractedScopes = useMemo(() => extractScopes(ScopesMap), [ScopesMap]);
 
   useEffect(() => {
@@ -35,7 +45,11 @@ export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({
 
   return (
     <div className="border rounded">
-      <AccessTokenScopeList scopeObject={ScopesMap} register={register} disabledScopes={disabledScopes} />
+      <AccessTokenScopeList
+        scopeObject={ScopesMap}
+        register={register}
+        disabledScopes={disabledScopes}
+      />
     </div>
   );
 };

+ 107 - 82
apps/app/src/client/components/Me/AccessTokenSettings.tsx

@@ -1,122 +1,147 @@
 import React, { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import CopyToClipboard from 'react-copy-to-clipboard';
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { IAccessTokenInfo } from '~/interfaces/access-token';
 import { useSWRxAccessToken } from '~/stores/personal-settings';
 
 import { AccessTokenForm } from './AccessTokenForm';
 import { AccessTokenList } from './AccessTokenList';
 
+const NewTokenDisplay = React.memo(
+  ({
+    newToken,
+    closeNewTokenDisplay,
+  }: {
+    newToken?: string;
+    closeNewTokenDisplay: () => void;
+  }): JSX.Element => {
+    const { t } = useTranslation();
+
+    // Handle successful copy
+    const handleCopySuccess = useCallback(() => {
+      toastSuccess(t('page_me_access_token.new_token.copy_to_clipboard'));
+    }, [t]);
+
+    if (newToken == null) {
+      return <></>;
+    }
 
-const NewTokenDisplay = React.memo(({ newToken, closeNewTokenDisplay }: { newToken?: string, closeNewTokenDisplay: () => void }): JSX.Element => {
-
-  const { t } = useTranslation();
-
-  // Handle successful copy
-  const handleCopySuccess = useCallback(() => {
-    toastSuccess(t('page_me_access_token.new_token.copy_to_clipboard'));
-  }, [t]);
-
-  if (newToken == null) {
-    return <></>;
-  }
-
-  return (
-    <div className="alert alert-success mb-4" role="alert" data-testid="grw-accesstoken-new-token-display">
-      <div className="d-flex justify-content-between align-items-center mb-2">
-        <h5 className="mb-0">
-          {t('page_me_access_token.new_token.title')}
-        </h5>
-        <button
-          type="button"
-          className="btn-close"
-          onClick={closeNewTokenDisplay}
-          aria-label="Close"
-        >
-        </button>
-      </div>
-
-      <p className="fw-bold mb-2">{t('page_me_access_token.new_token.message')}</p>
-
-      <div className="input-group mb-2">
-        <input
-          type="text"
-          className="form-control font-monospace"
-          value={newToken}
-          readOnly
-          data-vrt-blackout
-        />
-        <CopyToClipboard text={newToken} onCopy={handleCopySuccess}>
+    return (
+      <div
+        className="alert alert-success mb-4"
+        role="alert"
+        data-testid="grw-accesstoken-new-token-display"
+      >
+        <div className="d-flex justify-content-between align-items-center mb-2">
+          <h5 className="mb-0">{t('page_me_access_token.new_token.title')}</h5>
           <button
-            className="btn btn-outline-secondary"
             type="button"
-          >
-            <span className="material-symbols-outlined">content_copy</span>
-          </button>
-        </CopyToClipboard>
+            className="btn-close"
+            onClick={closeNewTokenDisplay}
+            aria-label="Close"
+          ></button>
+        </div>
+
+        <p className="fw-bold mb-2">
+          {t('page_me_access_token.new_token.message')}
+        </p>
+
+        <div className="input-group mb-2">
+          <input
+            type="text"
+            className="form-control font-monospace"
+            value={newToken}
+            readOnly
+            data-vrt-blackout
+          />
+          <CopyToClipboard text={newToken} onCopy={handleCopySuccess}>
+            <button className="btn btn-outline-secondary" type="button">
+              <span className="material-symbols-outlined">content_copy</span>
+            </button>
+          </CopyToClipboard>
+        </div>
       </div>
-    </div>
-  );
-});
+    );
+  },
+);
 
 export const AccessTokenSettings = React.memo((): JSX.Element => {
-
   const { t } = useTranslation();
 
   const [isFormOpen, setIsFormOpen] = React.useState<boolean>(false);
   const toggleFormOpen = useCallback(() => {
-    setIsFormOpen(prev => !prev);
+    setIsFormOpen((prev) => !prev);
   }, []);
 
   const [newToken, setNewToken] = React.useState<string | undefined>(undefined);
 
   const {
-    data: accessTokens, mutate, generateAccessToken, deleteAccessToken,
+    data: accessTokens,
+    mutate,
+    generateAccessToken,
+    deleteAccessToken,
   } = useSWRxAccessToken();
 
   const closeNewTokenDisplay = useCallback(() => {
     setNewToken(undefined);
   }, []);
 
-  const submitHandler = useCallback(async(info: IAccessTokenInfo) => {
-    try {
-      const result = await generateAccessToken(info);
-      mutate();
-      setIsFormOpen(false);
-
-      // Store the newly generated token to display to the user
-      if (result?.token) {
-        setNewToken(result.token);
+  const submitHandler = useCallback(
+    async (info: IAccessTokenInfo) => {
+      try {
+        const result = await generateAccessToken(info);
+        mutate();
+        setIsFormOpen(false);
+
+        // Store the newly generated token to display to the user
+        if (result?.token) {
+          setNewToken(result.token);
+        }
+
+        toastSuccess(
+          t('toaster.add_succeeded', {
+            target: t('page_me_access_token.access_token'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
       }
+    },
+    [t, generateAccessToken, mutate],
+  );
 
-      toastSuccess(t('toaster.add_succeeded', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, generateAccessToken, mutate, setIsFormOpen]);
-
-  const deleteHandler = useCallback(async(tokenId: string) => {
-    try {
-      await deleteAccessToken(tokenId);
-      mutate();
-      toastSuccess(t('toaster.delete_succeeded', { target: t('page_me_access_token.access_token'), ns: 'commons' }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [deleteAccessToken, mutate, t]);
+  const deleteHandler = useCallback(
+    async (tokenId: string) => {
+      try {
+        await deleteAccessToken(tokenId);
+        mutate();
+        toastSuccess(
+          t('toaster.delete_succeeded', {
+            target: t('page_me_access_token.access_token'),
+            ns: 'commons',
+          }),
+        );
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [deleteAccessToken, mutate, t],
+  );
 
   return (
     <>
-
       <div className="container p-0">
-
-        <NewTokenDisplay newToken={newToken} closeNewTokenDisplay={closeNewTokenDisplay} />
-        <AccessTokenList accessTokens={accessTokens ?? []} deleteHandler={deleteHandler} />
+        <NewTokenDisplay
+          newToken={newToken}
+          closeNewTokenDisplay={closeNewTokenDisplay}
+        />
+        <AccessTokenList
+          accessTokens={accessTokens ?? []}
+          deleteHandler={deleteHandler}
+        />
 
         <button
           className="btn btn-outline-secondary d-block mx-auto px-5"

+ 8 - 6
apps/app/src/client/components/Me/ApiSettings.tsx

@@ -1,5 +1,4 @@
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { useCurrentUser } from '~/states/global';
@@ -7,24 +6,27 @@ import { useCurrentUser } from '~/states/global';
 import { AccessTokenSettings } from './AccessTokenSettings';
 import { ApiTokenSettings } from './ApiTokenSettings';
 
-
 const ApiSettings = React.memo((): JSX.Element => {
-
   const { t } = useTranslation();
   const currentUser = useCurrentUser();
 
-  const shouldHideAccessTokenSettings = currentUser == null || !currentUser?.readOnly;
+  const shouldHideAccessTokenSettings =
+    currentUser == null || !currentUser?.readOnly;
 
   return (
     <>
       <div className="mt-4">
-        <h2 className="border-bottom pb-2 my-4 fs-4">{ t('API Token Settings') }</h2>
+        <h2 className="border-bottom pb-2 my-4 fs-4">
+          {t('API Token Settings')}
+        </h2>
         <ApiTokenSettings />
       </div>
 
       {shouldHideAccessTokenSettings && (
         <div className="mt-4">
-          <h2 className="border-bottom pb-2 my-4 fs-4">{ t('Access Token Settings') }</h2>
+          <h2 className="border-bottom pb-2 my-4 fs-4">
+            {t('Access Token Settings')}
+          </h2>
           <AccessTokenSettings />
         </div>
       )}

+ 34 - 39
apps/app/src/client/components/Me/ApiTokenSettings.tsx

@@ -1,67 +1,64 @@
 import React, { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
-import {
-  apiv3Put,
-} from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSWRxPersonalSettings } from '~/stores/personal-settings';
 
-
 export const ApiTokenSettings = React.memo((): JSX.Element => {
-
   const { t } = useTranslation();
-  const { data: personalSettingsData, mutate: mutateDatabaseData } = useSWRxPersonalSettings();
-
-  const submitHandler = useCallback(async() => {
+  const { data: personalSettingsData, mutate: mutateDatabaseData } =
+    useSWRxPersonalSettings();
 
+  const submitHandler = useCallback(async () => {
     try {
       await apiv3Put('/personal-setting/api-token');
       mutateDatabaseData();
 
-      toastSuccess(t('toaster.update_successed', { target: t('page_me_apitoken.api_token'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('page_me_apitoken.api_token'),
+          ns: 'commons',
+        }),
+      );
+    } catch (err) {
       toastError(err);
     }
-
   }, [mutateDatabaseData, t]);
 
   return (
     <>
       <div className="row mb-3">
-        <label htmlFor="apiToken" className="col-md-3 text-md-end col-form-label">{t('Current API Token')}</label>
+        <label
+          htmlFor="apiToken"
+          className="col-md-3 text-md-end col-form-label"
+        >
+          {t('Current API Token')}
+        </label>
         <div className="col-md-6">
-          {personalSettingsData?.apiToken != null
-            ? (
-              <input
-                data-testid="grw-api-settings-input"
-                data-vrt-blackout
-                className="form-control"
-                type="text"
-                name="apiToken"
-                value={personalSettingsData.apiToken}
-                readOnly
-              />
-            )
-            : (
-              <p>
-                { t('page_me_apitoken.notice.apitoken_issued') }
-              </p>
-            )}
+          {personalSettingsData?.apiToken != null ? (
+            <input
+              data-testid="grw-api-settings-input"
+              data-vrt-blackout
+              className="form-control"
+              type="text"
+              name="apiToken"
+              value={personalSettingsData.apiToken}
+              readOnly
+            />
+          ) : (
+            <p>{t('page_me_apitoken.notice.apitoken_issued')}</p>
+          )}
         </div>
       </div>
 
-
       <div className="row">
         <div className="offset-lg-2 col-lg-7">
-
           <p className="alert alert-warning">
-            { t('page_me_apitoken.notice.update_token1') }<br />
-            { t('page_me_apitoken.notice.update_token2') }
+            {t('page_me_apitoken.notice.update_token1')}
+            <br />
+            {t('page_me_apitoken.notice.update_token2')}
           </p>
-
         </div>
       </div>
 
@@ -77,8 +74,6 @@ export const ApiTokenSettings = React.memo((): JSX.Element => {
           </button>
         </div>
       </div>
-
     </>
-
   );
 });

+ 59 - 35
apps/app/src/client/components/Me/AssociateModal.tsx

@@ -1,26 +1,28 @@
-import React, { useState, useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
-  ModalHeader,
   ModalBody,
   ModalFooter,
+  ModalHeader,
   Nav,
   NavLink,
   TabContent,
   TabPane,
 } from 'reactstrap';
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useAssociateLdapAccount, useSWRxPersonalExternalAccounts } from '~/stores/personal-settings';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import {
+  useAssociateLdapAccount,
+  useSWRxPersonalExternalAccounts,
+} from '~/stores/personal-settings';
 
 import { LdapAuthTest } from '../Admin/Security/LdapAuthTest';
 
 type Props = {
-  isOpen: boolean,
-  onClose: () => void,
-}
+  isOpen: boolean;
+  onClose: () => void;
+};
 
 /**
  * AssociateModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
@@ -29,10 +31,13 @@ type AssociateModalSubstanceProps = {
   onClose: () => void;
 };
 
-const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Element => {
+const AssociateModalSubstance = (
+  props: AssociateModalSubstanceProps,
+): JSX.Element => {
   const { onClose } = props;
   const { t } = useTranslation();
-  const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
+  const { mutate: mutatePersonalExternalAccounts } =
+    useSWRxPersonalExternalAccounts();
   const { trigger: associateLdapAccount } = useAssociateLdapAccount();
 
   const [activeTab, setActiveTab] = useState(1);
@@ -45,29 +50,41 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
     setPassword('');
   }, [onClose]);
 
-  const clickAddLdapAccountHandler = useCallback(async() => {
+  const clickAddLdapAccountHandler = useCallback(async () => {
     try {
       await associateLdapAccount({ username, password });
       mutatePersonalExternalAccounts();
 
       closeModalHandler();
       toastSuccess(t('security_settings.updated_general_security_setting'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
-  }, [associateLdapAccount, closeModalHandler, mutatePersonalExternalAccounts, password, t, username]);
+  }, [
+    associateLdapAccount,
+    closeModalHandler,
+    mutatePersonalExternalAccounts,
+    password,
+    t,
+    username,
+  ]);
 
   const setTabToLdap = useCallback(() => setActiveTab(1), []);
   const setTabToGithub = useCallback(() => setActiveTab(2), []);
   const setTabToGoogle = useCallback(() => setActiveTab(3), []);
-  const handleUsernameChange = useCallback((username: string) => setUsername(username), []);
-  const handlePasswordChange = useCallback((password: string) => setPassword(password), []);
+  const handleUsernameChange = useCallback(
+    (username: string) => setUsername(username),
+    [],
+  );
+  const handlePasswordChange = useCallback(
+    (password: string) => setPassword(password),
+    [],
+  );
 
   return (
     <>
       <ModalHeader toggle={onClose}>
-        { t('admin:user_management.create_external_account') }
+        {t('admin:user_management.create_external_account')}
       </ModalHeader>
       <ModalBody>
         <div>
@@ -76,7 +93,10 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
               className={`${activeTab === 1 ? 'active' : ''} d-flex gap-1 align-items-center`}
               onClick={setTabToLdap}
             >
-              <span className="material-symbols-outlined fs-5">network_node</span> LDAP
+              <span className="material-symbols-outlined fs-5">
+                network_node
+              </span>{' '}
+              LDAP
             </NavLink>
             <NavLink
               className={`${activeTab === 2 ? 'active' : ''} d-flex gap-1 align-items-center`}
@@ -88,7 +108,8 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
               className={`${activeTab === 3 ? 'active' : ''} d-flex gap-1 align-items-center`}
               onClick={setTabToGoogle}
             >
-              <span className="growi-custom-icons">google</span> (TBD) Google OAuth
+              <span className="growi-custom-icons">google</span> (TBD) Google
+              OAuth
             </NavLink>
           </Nav>
           <TabContent activeTab={activeTab}>
@@ -100,24 +121,23 @@ const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Eleme
                 onChangePassword={handlePasswordChange}
               />
             </TabPane>
-            <TabPane tabId={2}>
-              TBD
-            </TabPane>
-            <TabPane tabId={3}>
-              TBD
-            </TabPane>
-            <TabPane tabId={4}>
-              TBD
-            </TabPane>
-            <TabPane tabId={5}>
-              TBD
-            </TabPane>
+            <TabPane tabId={2}>TBD</TabPane>
+            <TabPane tabId={3}>TBD</TabPane>
+            <TabPane tabId={4}>TBD</TabPane>
+            <TabPane tabId={5}>TBD</TabPane>
           </TabContent>
         </div>
       </ModalBody>
       <ModalFooter className="border-top-0">
-        <button type="button" className="btn btn-primary mt-3" data-testid="add-external-account-button" onClick={clickAddLdapAccountHandler}>
-          <span className="material-symbols-outlined" aria-hidden="true">add_circle</span>
+        <button
+          type="button"
+          className="btn btn-primary mt-3"
+          data-testid="add-external-account-button"
+          onClick={clickAddLdapAccountHandler}
+        >
+          <span className="material-symbols-outlined" aria-hidden="true">
+            add_circle
+          </span>
           {t('add')}
         </button>
       </ModalFooter>
@@ -132,11 +152,15 @@ const AssociateModal = (props: Props): JSX.Element => {
   const { isOpen, onClose } = props;
 
   return (
-    <Modal isOpen={isOpen} toggle={onClose} size="lg" data-testid="grw-associate-modal">
+    <Modal
+      isOpen={isOpen}
+      toggle={onClose}
+      size="lg"
+      data-testid="grw-associate-modal"
+    >
       {isOpen && <AssociateModalSubstance onClose={onClose} />}
     </Modal>
   );
 };
 
-
 export default AssociateModal;

+ 110 - 57
apps/app/src/client/components/Me/BasicInfoSettings.tsx

@@ -1,22 +1,22 @@
-import React, { useState, useEffect, type JSX } from 'react';
-
+import React, { type JSX, useEffect, useState } from 'react';
 import type { IUser } from '@growi/core/dist/interfaces';
 import { useAtomValue } from 'jotai';
-import { useTranslation, i18n } from 'next-i18next';
+import { i18n, useTranslation } from 'next-i18next';
 
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { registrationWhitelistAtom } from '~/states/server-configurations';
-import { useSWRxPersonalSettings, useUpdateBasicInfo } from '~/stores/personal-settings';
+import {
+  useSWRxPersonalSettings,
+  useUpdateBasicInfo,
+} from '~/stores/personal-settings';
 
 export const BasicInfoSettings = (): JSX.Element => {
   const { t } = useTranslation();
   const registrationWhitelist = useAtomValue(registrationWhitelistAtom);
 
-  const {
-    data: personalSettingsInfo, error,
-  } = useSWRxPersonalSettings();
+  const { data: personalSettingsInfo, error } = useSWRxPersonalSettings();
 
   // Form state management
   const [formData, setFormData] = useState<IUser | null>(null);
@@ -30,23 +30,26 @@ export const BasicInfoSettings = (): JSX.Element => {
 
   const { trigger: updateBasicInfo, isMutating } = useUpdateBasicInfo();
 
-  const submitHandler = async() => {
+  const submitHandler = async () => {
     try {
       if (formData == null) {
         throw new Error('personalSettingsInfo is not loaded');
       }
       await updateBasicInfo(formData);
-      toastSuccess(t('toaster.update_successed', { target: t('Basic Info'), ns: 'commons' }));
-    }
-    catch (errs) {
+      toastSuccess(
+        t('toaster.update_successed', {
+          target: t('Basic Info'),
+          ns: 'commons',
+        }),
+      );
+    } catch (errs) {
       const err = errs[0];
       const message = err.message;
       const code = err.code;
 
       if (code === 'email-is-already-in-use') {
         toastError(t('alert.email_is_already_in_use', { ns: 'commons' }));
-      }
-      else {
+      } else {
         toastError(message);
       }
     }
@@ -59,46 +62,65 @@ export const BasicInfoSettings = (): JSX.Element => {
     setFormData({ ...formData, ...updateData });
   };
 
-
   return (
     <>
-
       <div className="row mt-3 mt-md-4">
-        <label htmlFor="userForm[name]" className="text-start text-md-end col-md-3 col-form-label">{t('Name')}</label>
+        <label
+          htmlFor="userForm[name]"
+          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="userForm[name]"
             value={formData?.name || ''}
-            onChange={e => changePersonalSettingsHandler({ name: e.target.value })}
+            onChange={(e) =>
+              changePersonalSettingsHandler({ name: e.target.value })
+            }
           />
         </div>
       </div>
 
       <div className="row mt-3">
-        <label htmlFor="userForm[email]" className="text-start text-md-end col-md-3 col-form-label">{t('Email')}</label>
+        <label
+          htmlFor="userForm[email]"
+          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="userForm[email]"
             value={formData?.email || ''}
-            onChange={e => changePersonalSettingsHandler({ email: e.target.value })}
+            onChange={(e) =>
+              changePersonalSettingsHandler({ email: e.target.value })
+            }
           />
-          {registrationWhitelist != null && registrationWhitelist.length !== 0 && (
-            <div className="form-text text-muted">
-              {t('page_register.form_help.email')}
-              <ul>
-                {registrationWhitelist.map(data => <li key={data}><code>{data}</code></li>)}
-              </ul>
-            </div>
-          )}
+          {registrationWhitelist != null &&
+            registrationWhitelist.length !== 0 && (
+              <div className="form-text text-muted">
+                {t('page_register.form_help.email')}
+                <ul>
+                  {registrationWhitelist.map((data) => (
+                    <li key={data}>
+                      <code>{data}</code>
+                    </li>
+                  ))}
+                </ul>
+              </div>
+            )}
         </div>
       </div>
 
       <div className="row mt-3">
-        <label className="text-start text-md-end col-md-3 col-form-label">{t('Disclose E-mail')}</label>
+        <span className="text-start text-md-end col-md-3 col-form-label">
+          {t('Disclose E-mail')}
+        </span>
         <div className="col-md-6 my-auto">
           <div className="form-check form-check-inline me-4">
             <input
@@ -107,9 +129,16 @@ export const BasicInfoSettings = (): JSX.Element => {
               className="form-check-input"
               name="userForm[isEmailPublished]"
               checked={formData?.isEmailPublished === true}
-              onChange={() => changePersonalSettingsHandler({ isEmailPublished: true })}
+              onChange={() =>
+                changePersonalSettingsHandler({ isEmailPublished: true })
+              }
             />
-            <label className="form-label form-check-label mb-0" htmlFor="radioEmailShow">{t('Show')}</label>
+            <label
+              className="form-label form-check-label mb-0"
+              htmlFor="radioEmailShow"
+            >
+              {t('Show')}
+            </label>
           </div>
           <div className="form-check form-check-inline">
             <input
@@ -118,40 +147,63 @@ export const BasicInfoSettings = (): JSX.Element => {
               className="form-check-input"
               name="userForm[isEmailPublished]"
               checked={formData?.isEmailPublished === false}
-              onChange={() => changePersonalSettingsHandler({ isEmailPublished: false })}
+              onChange={() =>
+                changePersonalSettingsHandler({ isEmailPublished: false })
+              }
             />
-            <label className="form-label form-check-label mb-0" htmlFor="radioEmailHide">{t('Hide')}</label>
+            <label
+              className="form-label form-check-label mb-0"
+              htmlFor="radioEmailHide"
+            >
+              {t('Hide')}
+            </label>
           </div>
         </div>
       </div>
 
       <div className="row mt-3">
-        <label className="text-start text-md-end col-md-3 col-form-label">{t('Language')}</label>
+        <span className="text-start text-md-end col-md-3 col-form-label">
+          {t('Language')}
+        </span>
         <div className="col-md-6 my-auto">
-          {
-            i18nConfig.locales.map((locale) => {
-              if (i18n == null) { return }
-              const fixedT = i18n.getFixedT(locale);
-
-              return (
-                <div key={locale} className="form-check form-check-inline me-4">
-                  <input
-                    type="radio"
-                    id={`radioLang${locale}`}
-                    className="form-check-input"
-                    name="userForm[lang]"
-                    checked={formData?.lang === locale}
-                    onChange={() => changePersonalSettingsHandler({ lang: locale as IUser['lang'] })}
-                  />
-                  <label className="form-label form-check-label mb-0" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name') as string}</label>
-                </div>
-              );
-            })
-          }
+          {i18nConfig.locales.map((locale) => {
+            if (i18n == null) {
+              return null;
+            }
+            const fixedT = i18n.getFixedT(locale);
+
+            return (
+              <div key={locale} className="form-check form-check-inline me-4">
+                <input
+                  type="radio"
+                  id={`radioLang${locale}`}
+                  className="form-check-input"
+                  name="userForm[lang]"
+                  checked={formData?.lang === locale}
+                  onChange={() =>
+                    changePersonalSettingsHandler({
+                      lang: locale as IUser['lang'],
+                    })
+                  }
+                />
+                <label
+                  className="form-label form-check-label mb-0"
+                  htmlFor={`radioLang${locale}`}
+                >
+                  {fixedT('meta.display_name') as string}
+                </label>
+              </div>
+            );
+          })}
         </div>
       </div>
       <div className="row mt-3">
-        <label htmlFor="userForm[slackMemberId]" className="text-start text-md-end col-md-3 col-form-label">{t('Slack Member ID')}</label>
+        <label
+          htmlFor="userForm[slackMemberId]"
+          className="text-start text-md-end col-md-3 col-form-label"
+        >
+          {t('Slack Member ID')}
+        </label>
         <div className="col-md-6">
           <input
             className="form-control"
@@ -159,7 +211,9 @@ export const BasicInfoSettings = (): JSX.Element => {
             key="slackMemberId"
             name="userForm[slackMemberId]"
             value={formData?.slackMemberId || ''}
-            onChange={e => changePersonalSettingsHandler({ slackMemberId: e.target.value })}
+            onChange={(e) =>
+              changePersonalSettingsHandler({ slackMemberId: e.target.value })
+            }
           />
         </div>
       </div>
@@ -177,7 +231,6 @@ export const BasicInfoSettings = (): JSX.Element => {
           </button>
         </div>
       </div>
-
     </>
   );
 };

+ 50 - 24
apps/app/src/client/components/Me/ColorModeSettings.tsx

@@ -1,66 +1,92 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { Themes, useNextThemes } from '~/stores-universal/use-next-themes';
 
-
 type ColorModeSettingsButtonProps = {
-  isActive: boolean,
-  children?: React.ReactNode,
-  onClick?: () => void,
-}
+  isActive: boolean;
+  children?: React.ReactNode;
+  onClick?: () => void;
+};
 
-const ColorModeSettingsButton = ({ isActive, children, onClick }: ColorModeSettingsButtonProps): JSX.Element => {
+const ColorModeSettingsButton = ({
+  isActive,
+  children,
+  onClick,
+}: ColorModeSettingsButtonProps): JSX.Element => {
   return (
     <button
       type="button"
       onClick={onClick}
       className={`btn py-2 px-4 fw-bold border-3 ${isActive ? 'btn-outline-primary' : 'btn-outline-neutral-secondary'}`}
     >
-      { children }
+      {children}
     </button>
   );
 };
 
-
 export const ColorModeSettings = (): JSX.Element => {
   const { t } = useTranslation();
 
   const { setTheme, theme } = useNextThemes();
 
-  const isActive = useCallback((targetTheme: Themes) => {
-    return targetTheme === theme;
-  }, [theme]);
+  const isActive = useCallback(
+    (targetTheme: Themes) => {
+      return targetTheme === theme;
+    },
+    [theme],
+  );
 
   return (
     <div>
-      <h2 className="border-bottom pb-2 mb-4 fs-4">{t('color_mode_settings.settings')}</h2>
+      <h2 className="border-bottom pb-2 mb-4 fs-4">
+        {t('color_mode_settings.settings')}
+      </h2>
 
       <div className="offset-md-3">
-
         <div className="d-flex column-gap-3">
-
-          <ColorModeSettingsButton isActive={isActive(Themes.LIGHT)} onClick={() => { setTheme(Themes.LIGHT) }}>
-            <span className="material-symbols-outlined fs-5 me-1">light_mode</span>
+          <ColorModeSettingsButton
+            isActive={isActive(Themes.LIGHT)}
+            onClick={() => {
+              setTheme(Themes.LIGHT);
+            }}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">
+              light_mode
+            </span>
             <span>{t('color_mode_settings.light')}</span>
           </ColorModeSettingsButton>
 
-          <ColorModeSettingsButton isActive={isActive(Themes.DARK)} onClick={() => { setTheme(Themes.DARK) }}>
-            <span className="material-symbols-outlined fs-5 me-1">dark_mode</span>
+          <ColorModeSettingsButton
+            isActive={isActive(Themes.DARK)}
+            onClick={() => {
+              setTheme(Themes.DARK);
+            }}
+          >
+            <span className="material-symbols-outlined fs-5 me-1">
+              dark_mode
+            </span>
             <span>{t('color_mode_settings.dark')}</span>
           </ColorModeSettingsButton>
 
-          <ColorModeSettingsButton isActive={isActive(Themes.SYSTEM)} onClick={() => { setTheme(Themes.SYSTEM) }}>
+          <ColorModeSettingsButton
+            isActive={isActive(Themes.SYSTEM)}
+            onClick={() => {
+              setTheme(Themes.SYSTEM);
+            }}
+          >
             <span className="material-symbols-outlined fs-5 me-1">devices</span>
             <span>{t('color_mode_settings.system')}</span>
           </ColorModeSettingsButton>
-
         </div>
 
         <div className="mt-3 text-muted small">
-          {/* eslint-disable-next-line react/no-danger */}
-          <span dangerouslySetInnerHTML={{ __html: t('color_mode_settings.description') }} />
+          <span
+            // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+            dangerouslySetInnerHTML={{
+              __html: t('color_mode_settings.description'),
+            }}
+          />
         </div>
       </div>
     </div>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů