Yuki Takei 5 месяцев назад
Родитель
Сommit
967f60dad7

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

@@ -1,9 +1,11 @@
 import type { JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister, FieldValues } from 'react-hook-form';
 
 
 export type AwsSettingMoleculeProps = {
+  register: UseFormRegister<FieldValues>
   s3ReferenceFileWithRelayMode
   s3Region
   s3CustomEndpoint
@@ -76,10 +78,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
             className="form-control"
             placeholder={`${t('eg')} ap-northeast-1`}
-            value={props.s3Region || ''}
-            onChange={(e) => {
-              props?.onChangeS3Region(e.target.value);
-            }}
+            {...props.register('s3Region')}
           />
         </div>
       </div>
@@ -93,10 +92,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             type="text"
             placeholder={`${t('eg')} http://localhost:9000`}
-            value={props.s3CustomEndpoint || ''}
-            onChange={(e) => {
-              props?.onChangeS3CustomEndpoint(e.target.value);
-            }}
+            {...props.register('s3CustomEndpoint')}
           />
           <p className="form-text text-muted">{t('admin:app_setting.custom_endpoint_change')}</p>
         </div>
@@ -111,10 +107,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
             className="form-control"
             type="text"
             placeholder={`${t('eg')} crowi`}
-            value={props.s3Bucket || ''}
-            onChange={(e) => {
-              props.onChangeS3Bucket(e.target.value);
-            }}
+            {...props.register('s3Bucket')}
           />
         </div>
       </div>
@@ -127,10 +120,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
             className="form-control"
             type="text"
-            value={props.s3AccessKeyId || ''}
-            onChange={(e) => {
-              props?.onChangeS3AccessKeyId(e.target.value);
-            }}
+            {...props.register('s3AccessKeyId')}
           />
         </div>
       </div>
@@ -143,9 +133,7 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <input
             className="form-control"
             type="text"
-            onChange={(e) => {
-              props?.onChangeS3SecretAccessKey(e.target.value);
-            }}
+            {...props.register('s3SecretAccessKey')}
           />
           <p className="form-text text-muted">{t('admin:app_setting.s3_secret_access_key_input_description')}</p>
         </div>

+ 10 - 20
apps/app/src/client/components/Admin/App/AzureSetting.tsx

@@ -1,11 +1,13 @@
 import type { JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister, FieldValues } from 'react-hook-form';
 
 import MaskedInput from './MaskedInput';
 
 
 export type AzureSettingMoleculeProps = {
+  register: UseFormRegister<FieldValues>
   azureReferenceFileWithRelayMode
   azureUseOnlyEnvVars
   azureTenantId
@@ -32,15 +34,10 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
   const {
     azureReferenceFileWithRelayMode,
     azureUseOnlyEnvVars,
-    azureTenantId,
-    azureClientId,
-    azureClientSecret,
-    azureStorageAccountName,
     envAzureTenantId,
     envAzureClientId,
     envAzureClientSecret,
     envAzureStorageAccountName,
-    azureStorageContainerName,
     envAzureStorageContainerName,
   } = props;
 
@@ -116,10 +113,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_tenant_id')}</th>
             <td>
               <MaskedInput
-                name="azureTenantId"
+                register={props.register}
+                fieldName="azureTenantId"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureTenantId}
-                onChange={e => props?.onChangeAzureTenantId(e.target.value)}
               />
             </td>
             <td>
@@ -134,10 +130,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_client_id')}</th>
             <td>
               <MaskedInput
-                name="azureClientId"
+                register={props.register}
+                fieldName="azureClientId"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureClientId}
-                onChange={e => props?.onChangeAzureClientId(e.target.value)}
               />
             </td>
             <td>
@@ -152,10 +147,9 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
             <th>{t('admin:app_setting.azure_client_secret')}</th>
             <td>
               <MaskedInput
-                name="azureClientSecret"
+                register={props.register}
+                fieldName="azureClientSecret"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureClientSecret}
-                onChange={e => props?.onChangeAzureClientSecret(e.target.value)}
               />
             </td>
             <td>
@@ -172,10 +166,8 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <input
                 className="form-control"
                 type="text"
-                name="azureStorageAccountName"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureStorageAccountName}
-                onChange={e => props?.onChangeAzureStorageAccountName(e.target.value)}
+                {...props.register('azureStorageAccountName')}
               />
             </td>
             <td>
@@ -192,10 +184,8 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <input
                 className="form-control"
                 type="text"
-                name="azureStorageContainerName"
                 readOnly={azureUseOnlyEnvVars}
-                value={azureStorageContainerName}
-                onChange={e => props?.onChangeAzureStorageContainerName(e.target.value)}
+                {...props.register('azureStorageContainerName')}
               />
             </td>
             <td>

+ 53 - 6
apps/app/src/client/components/Admin/App/FileUploadSetting.tsx

@@ -1,7 +1,8 @@
 import type { ChangeEvent, JSX } from 'react';
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useForm } from 'react-hook-form';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
@@ -22,7 +23,8 @@ type FileUploadSettingMoleculeProps = {
   isFixedFileUploadByEnvVar: boolean
   envFileUploadType?: string
   onChangeFileUploadType: (e: ChangeEvent, type: string) => void
-} & AwsSettingMoleculeProps & GcsSettingMoleculeProps & AzureSettingMoleculeProps;
+  register: ReturnType<typeof useForm>['register']
+} & Omit<AwsSettingMoleculeProps, 'register'> & Omit<GcsSettingMoleculeProps, 'register'> & Omit<AzureSettingMoleculeProps, 'register'>;
 
 export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMoleculeProps): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
@@ -78,6 +80,7 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
 
       {props.fileUploadType === 'aws' && (
         <AwsSettingMolecule
+          register={props.register}
           s3ReferenceFileWithRelayMode={props.s3ReferenceFileWithRelayMode}
           s3Region={props.s3Region}
           s3CustomEndpoint={props.s3CustomEndpoint}
@@ -94,6 +97,7 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
       )}
       {props.fileUploadType === 'gcs' && (
         <GcsSettingMolecule
+          register={props.register}
           gcsReferenceFileWithRelayMode={props.gcsReferenceFileWithRelayMode}
           gcsUseOnlyEnvVars={props.gcsUseOnlyEnvVars}
           gcsApiKeyJsonPath={props.gcsApiKeyJsonPath}
@@ -110,6 +114,7 @@ export const FileUploadSettingMolecule = React.memo((props: FileUploadSettingMol
       )}
       {props.fileUploadType === 'azure' && (
         <AzureSettingMolecule
+          register={props.register}
           azureReferenceFileWithRelayMode={props.azureReferenceFileWithRelayMode}
           azureUseOnlyEnvVars={props.azureUseOnlyEnvVars}
           azureTenantId={props.azureTenantId}
@@ -159,8 +164,49 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
     envAzureStorageAccountName, envAzureStorageContainerName,
   } = adminAppContainer.state;
 
-  const submitHandler = useCallback(async() => {
+  const { register, handleSubmit, reset } = useForm();
+
+  // Sync form with container state
+  useEffect(() => {
+    reset({
+      s3Region,
+      s3CustomEndpoint,
+      s3Bucket,
+      s3AccessKeyId,
+      gcsApiKeyJsonPath,
+      gcsBucket,
+      gcsUploadNamespace,
+      azureTenantId,
+      azureClientId,
+      azureClientSecret,
+      azureStorageAccountName,
+      azureStorageContainerName,
+    });
+  }, [
+    reset,
+    s3Region, s3CustomEndpoint, s3Bucket, s3AccessKeyId,
+    gcsApiKeyJsonPath, gcsBucket, gcsUploadNamespace,
+    azureTenantId, azureClientId, azureClientSecret,
+    azureStorageAccountName, azureStorageContainerName,
+  ]);
+
+  const submitHandler = useCallback(async(data) => {
     try {
+      // Update container state with form data
+      await adminAppContainer.changeS3Region(data.s3Region ?? '');
+      await adminAppContainer.changeS3CustomEndpoint(data.s3CustomEndpoint ?? '');
+      await adminAppContainer.changeS3Bucket(data.s3Bucket ?? '');
+      await adminAppContainer.changeS3AccessKeyId(data.s3AccessKeyId ?? '');
+      await adminAppContainer.changeS3SecretAccessKey(data.s3SecretAccessKey ?? '');
+      await adminAppContainer.changeGcsApiKeyJsonPath(data.gcsApiKeyJsonPath ?? '');
+      await adminAppContainer.changeGcsBucket(data.gcsBucket ?? '');
+      await adminAppContainer.changeGcsUploadNamespace(data.gcsUploadNamespace ?? '');
+      await adminAppContainer.changeAzureTenantId(data.azureTenantId ?? '');
+      await adminAppContainer.changeAzureClientId(data.azureClientId ?? '');
+      await adminAppContainer.changeAzureClientSecret(data.azureClientSecret ?? '');
+      await adminAppContainer.changeAzureStorageAccountName(data.azureStorageAccountName ?? '');
+      await adminAppContainer.changeAzureStorageContainerName(data.azureStorageContainerName ?? '');
+
       await adminAppContainer.updateFileUploadSettingHandler();
       toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings'), ns: 'commons' }));
     }
@@ -241,8 +287,9 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
   }, [adminAppContainer]);
 
   return (
-    <>
+    <form onSubmit={handleSubmit(submitHandler)}>
       <FileUploadSettingMolecule
+        register={register}
         fileUploadType={fileUploadType}
         isFixedFileUploadByEnvVar={isFixedFileUploadByEnvVar}
         envFileUploadType={envFileUploadType}
@@ -290,8 +337,8 @@ const FileUploadSetting = (props: FileUploadSettingProps): JSX.Element => {
         onChangeAzureStorageAccountName={onChangeAzureStorageAccountNameHandler}
         onChangeAzureStorageContainerName={onChangeAzureStorageContainerNameHandler}
       />
-      <AdminUpdateButtonRow onClick={submitHandler} disabled={retrieveError != null} />
-    </>
+      <AdminUpdateButtonRow disabled={retrieveError != null} />
+    </form>
   );
 };
 

+ 5 - 12
apps/app/src/client/components/Admin/App/GcsSetting.tsx

@@ -1,9 +1,11 @@
 import type { JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import type { UseFormRegister, FieldValues } from 'react-hook-form';
 
 
 export type GcsSettingMoleculeProps = {
+  register: UseFormRegister<FieldValues>
   gcsReferenceFileWithRelayMode
   gcsUseOnlyEnvVars
   gcsApiKeyJsonPath
@@ -24,11 +26,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
   const {
     gcsReferenceFileWithRelayMode,
     gcsUseOnlyEnvVars,
-    gcsApiKeyJsonPath,
     envGcsApiKeyJsonPath,
-    gcsBucket,
     envGcsBucket,
-    gcsUploadNamespace,
     envGcsUploadNamespace,
   } = props;
 
@@ -106,10 +105,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
                 className="form-control"
                 type="text"
-                name="gcsApiKeyJsonPath"
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsApiKeyJsonPath}
-                onChange={e => props?.onChangeGcsApiKeyJsonPath(e.target.value)}
+                {...props.register('gcsApiKeyJsonPath')}
               />
             </td>
             <td>
@@ -126,10 +123,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
                 className="form-control"
                 type="text"
-                name="gcsBucket"
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsBucket}
-                onChange={e => props?.onChangeGcsBucket(e.target.value)}
+                {...props.register('gcsBucket')}
               />
             </td>
             <td>
@@ -146,10 +141,8 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <input
                 className="form-control"
                 type="text"
-                name="gcsUploadNamespace"
                 readOnly={gcsUseOnlyEnvVars}
-                value={gcsUploadNamespace}
-                onChange={e => props?.onChangeGcsUploadNamespace(e.target.value)}
+                {...props.register('gcsUploadNamespace')}
               />
             </td>
             <td>

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

@@ -1,13 +1,18 @@
+import type { ChangeEvent } from 'react';
 import { useState, type JSX } from 'react';
 
+import type { UseFormRegister, FieldValues } from 'react-hook-form';
+
 import styles from './MaskedInput.module.scss';
 
 type Props = {
-  name: string
+  name?: string
   readOnly: boolean
-  value: string
-  onChange?: (e: any) => void
+  value?: string
+  onChange?: (e: ChangeEvent<HTMLInputElement>) => void
   tabIndex?: number | undefined
+  register?: UseFormRegister<FieldValues>
+  fieldName?: string
 };
 
 export default function MaskedInput(props: Props): JSX.Element {
@@ -17,19 +22,26 @@ export default function MaskedInput(props: Props): JSX.Element {
   };
 
   const {
-    name, readOnly, value, onChange, tabIndex,
+    name, readOnly, value, onChange, tabIndex, register, fieldName,
   } = props;
 
+  // Use register if provided, otherwise use value/onChange
+  const inputProps = register && fieldName
+    ? register(fieldName)
+    : {
+      name,
+      value,
+      onChange,
+    };
+
   return (
     <div className={styles.MaskedInput}>
       <input
         className="form-control"
         type={passwordShown ? 'text' : 'password'}
-        name={name}
         readOnly={readOnly}
-        value={value}
-        onChange={onChange}
         tabIndex={tabIndex}
+        {...inputProps}
       />
       <span onClick={togglePassword} className={styles.PasswordReveal}>
         {passwordShown ? (

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

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

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

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

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

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

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

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