Explorar el Código

Merge pull request #10455 from growilabs/fix/file-upload-setting

fix: file upload setting
mergify[bot] hace 5 meses
padre
commit
e206081765

+ 2 - 0
apps/app/package.json

@@ -288,6 +288,7 @@
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-input-autosize": "^2.2.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-scroll": "^1.8.4",
     "@types/react-stickynode": "^4.0.3",
     "@types/react-stickynode": "^4.0.3",
+    "@types/supertest": "^6.0.3",
     "@types/testing-library__dom": "^7.5.0",
     "@types/testing-library__dom": "^7.5.0",
     "@types/throttle-debounce": "^5.0.1",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unist": "^3.0.3",
     "@types/unist": "^3.0.3",
@@ -337,6 +338,7 @@
     "simplebar-react": "^2.3.6",
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.7.5",
     "socket.io-client": "^4.7.5",
     "source-map-loader": "^4.0.1",
     "source-map-loader": "^4.0.1",
+    "supertest": "^7.1.4",
     "swagger2openapi": "^7.0.8",
     "swagger2openapi": "^7.0.8",
     "unist-util-is": "^6.0.0",
     "unist-util-is": "^6.0.0",
     "unist-util-visit-parents": "^6.0.0"
     "unist-util-visit-parents": "^6.0.0"

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

@@ -1,23 +1,14 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import type { UseFormRegister, FieldValues } from 'react-hook-form';
+import type { UseFormRegister } from 'react-hook-form';
 
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 
 export type AwsSettingMoleculeProps = {
 export type AwsSettingMoleculeProps = {
-  register: UseFormRegister<FieldValues>
-  s3ReferenceFileWithRelayMode
-  s3Region
-  s3CustomEndpoint
-  s3Bucket
-  s3AccessKeyId
-  s3SecretAccessKey
+  register: UseFormRegister<FileUploadFormValues>
+  s3ReferenceFileWithRelayMode: boolean
   onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void
   onChangeS3ReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeS3Region: (val: string) => void
-  onChangeS3CustomEndpoint: (val: string) => void
-  onChangeS3Bucket: (val: string) => void
-  onChangeS3AccessKeyId: (val: string) => void
-  onChangeS3SecretAccessKey: (val: string) => void
 };
 };
 
 
 export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => {
 export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element => {
@@ -25,7 +16,6 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
 
 
   return (
   return (
     <>
     <>
-
       <div className="row my-3">
       <div className="row my-3">
         <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">
           {t('admin:app_setting.file_delivery_method')}
           {t('admin:app_setting.file_delivery_method')}
@@ -48,16 +38,16 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(true) }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               </button>
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeS3ReferenceFileWithRelayMode(false) }}
+                onClick={() => { props.onChangeS3ReferenceFileWithRelayMode(false) }}
               >
               >
-                { t('admin:app_setting.file_delivery_method_redirect')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
               </button>
             </div>
             </div>
 
 
@@ -138,8 +128,6 @@ export const AwsSettingMolecule = (props: AwsSettingMoleculeProps): JSX.Element
           <p className="form-text text-muted">{t('admin:app_setting.s3_secret_access_key_input_description')}</p>
           <p className="form-text text-muted">{t('admin:app_setting.s3_secret_access_key_input_description')}</p>
         </div>
         </div>
       </div>
       </div>
-
-
     </>
     </>
   );
   );
 };
 };

+ 13 - 25
apps/app/src/client/components/Admin/App/AzureSetting.tsx

@@ -1,31 +1,21 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import type { UseFormRegister, FieldValues } from 'react-hook-form';
+import type { UseFormRegister } from 'react-hook-form';
 
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 import MaskedInput from './MaskedInput';
 import MaskedInput from './MaskedInput';
 
 
-
 export type AzureSettingMoleculeProps = {
 export type AzureSettingMoleculeProps = {
-  register: UseFormRegister<FieldValues>
-  azureReferenceFileWithRelayMode
-  azureUseOnlyEnvVars
-  azureTenantId
-  azureClientId
-  azureClientSecret
-  azureStorageAccountName
-  azureStorageContainerName
-  envAzureTenantId?
-  envAzureClientId?
-  envAzureClientSecret?
-  envAzureStorageAccountName?
-  envAzureStorageContainerName?
+  register: UseFormRegister<FileUploadFormValues>
+  azureReferenceFileWithRelayMode: boolean
+  azureUseOnlyEnvVars: boolean
+  envAzureTenantId?: string
+  envAzureClientId?: string
+  envAzureClientSecret?: string
+  envAzureStorageAccountName?: string
+  envAzureStorageContainerName?: string
   onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
   onChangeAzureReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeAzureTenantId: (val: string) => void
-  onChangeAzureClientId: (val: string) => void
-  onChangeAzureClientSecret: (val: string) => void
-  onChangeAzureStorageAccountName: (val: string) => void
-  onChangeAzureStorageContainerName: (val: string) => void
 };
 };
 
 
 export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
 export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Element => {
@@ -43,7 +33,6 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
 
 
   return (
   return (
     <>
     <>
-
       <div className="row form-group my-3">
       <div className="row form-group my-3">
         <label className="text-left text-md-right col-md-3 col-form-label">
         <label className="text-left text-md-right col-md-3 col-form-label">
           {t('admin:app_setting.file_delivery_method')}
           {t('admin:app_setting.file_delivery_method')}
@@ -66,16 +55,16 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(true) }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               </button>
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeAzureReferenceFileWithRelayMode(false) }}
+                onClick={() => { props.onChangeAzureReferenceFileWithRelayMode(false) }}
               >
               >
-                { t('admin:app_setting.file_delivery_method_redirect')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
               </button>
             </div>
             </div>
 
 
@@ -198,7 +187,6 @@ export const AzureSettingMolecule = (props: AzureSettingMoleculeProps): JSX.Elem
           </tr>
           </tr>
         </tbody>
         </tbody>
       </table>
       </table>
-
     </>
     </>
   );
   );
 };
 };

+ 112 - 282
apps/app/src/client/components/Admin/App/FileUploadSetting.tsx

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

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

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

+ 11 - 18
apps/app/src/client/components/Admin/App/GcsSetting.tsx

@@ -1,23 +1,18 @@
 import type { JSX } from 'react';
 import type { JSX } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import type { UseFormRegister, FieldValues } from 'react-hook-form';
+import type { UseFormRegister } from 'react-hook-form';
 
 
+import type { FileUploadFormValues } from './FileUploadSetting.types';
 
 
 export type GcsSettingMoleculeProps = {
 export type GcsSettingMoleculeProps = {
-  register: UseFormRegister<FieldValues>
-  gcsReferenceFileWithRelayMode
-  gcsUseOnlyEnvVars
-  gcsApiKeyJsonPath
-  gcsBucket
-  gcsUploadNamespace
-  envGcsApiKeyJsonPath?
-  envGcsBucket?
-  envGcsUploadNamespace?
+  register: UseFormRegister<FileUploadFormValues>
+  gcsReferenceFileWithRelayMode: boolean
+  gcsUseOnlyEnvVars: boolean
+  envGcsApiKeyJsonPath?: string
+  envGcsBucket?: string
+  envGcsUploadNamespace?: string
   onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void
   onChangeGcsReferenceFileWithRelayMode: (val: boolean) => void
-  onChangeGcsApiKeyJsonPath: (val: string) => void
-  onChangeGcsBucket: (val: string) => void
-  onChangeGcsUploadNamespace: (val: string) => void
 };
 };
 
 
 export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
 export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element => {
@@ -33,7 +28,6 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
 
 
   return (
   return (
     <>
     <>
-
       <div className="row my-3">
       <div className="row my-3">
         <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">
           {t('admin:app_setting.file_delivery_method')}
           {t('admin:app_setting.file_delivery_method')}
@@ -56,16 +50,16 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(true) }}
+                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(true) }}
               >
               >
                 {t('admin:app_setting.file_delivery_method_relay')}
                 {t('admin:app_setting.file_delivery_method_relay')}
               </button>
               </button>
               <button
               <button
                 className="dropdown-item"
                 className="dropdown-item"
                 type="button"
                 type="button"
-                onClick={() => { props?.onChangeGcsReferenceFileWithRelayMode(false) }}
+                onClick={() => { props.onChangeGcsReferenceFileWithRelayMode(false) }}
               >
               >
-                { t('admin:app_setting.file_delivery_method_redirect')}
+                {t('admin:app_setting.file_delivery_method_redirect')}
               </button>
               </button>
             </div>
             </div>
 
 
@@ -155,7 +149,6 @@ export const GcsSettingMolecule = (props: GcsSettingMoleculeProps): JSX.Element
           </tr>
           </tr>
         </tbody>
         </tbody>
       </table>
       </table>
-
     </>
     </>
   );
   );
 };
 };

+ 3 - 2
apps/app/src/client/components/Admin/App/MaskedInput.tsx

@@ -1,7 +1,7 @@
 import type { ChangeEvent } from 'react';
 import type { ChangeEvent } from 'react';
 import { useState, type JSX } from 'react';
 import { useState, type JSX } from 'react';
 
 
-import type { UseFormRegister, FieldValues } from 'react-hook-form';
+import type { UseFormRegister } from 'react-hook-form';
 
 
 import styles from './MaskedInput.module.scss';
 import styles from './MaskedInput.module.scss';
 
 
@@ -11,7 +11,8 @@ type Props = {
   value?: string
   value?: string
   onChange?: (e: ChangeEvent<HTMLInputElement>) => void
   onChange?: (e: ChangeEvent<HTMLInputElement>) => void
   tabIndex?: number | undefined
   tabIndex?: number | undefined
-  register?: UseFormRegister<FieldValues>
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  register?: UseFormRegister<any>
   fieldName?: string
   fieldName?: string
 };
 };
 
 

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

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

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

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

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

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

+ 0 - 2
apps/app/src/interfaces/res/admin/app-settings.ts

@@ -53,8 +53,6 @@ export type IResAppSettings = {
   envAzureStorageAccountName: string;
   envAzureStorageAccountName: string;
   envAzureStorageContainerName: string;
   envAzureStorageContainerName: string;
 
 
-  isEnabledPlugins: boolean;
-
   isAppSiteUrlHashed: boolean;
   isAppSiteUrlHashed: boolean;
 
 
   isMaintenanceMode: boolean;
   isMaintenanceMode: boolean;

+ 337 - 0
apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.integ.ts

@@ -0,0 +1,337 @@
+import { toNonBlankString } from '@growi/core/dist/interfaces';
+import type { Request } from 'express';
+import express from 'express';
+import mockRequire from 'mock-require';
+import request from 'supertest';
+import { mock } from 'vitest-mock-extended';
+
+import type Crowi from '~/server/crowi';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import { configManager } from '~/server/service/config-manager';
+import type { S2sMessagingService } from '~/server/service/s2s-messaging/base';
+
+// Mock middlewares using mock-require BEFORE importing the router
+const mockActivityId = '507f1f77bcf86cd799439011';
+
+// Mock the dependencies that login-required.js and admin-required.js need
+mockRequire.stopAll();
+
+mockRequire('~/server/middlewares/access-token-parser', {
+  accessTokenParser: () => (_req: Request, _res: ApiV3Response, next: () => void) => next(),
+});
+
+mockRequire('../../../middlewares/login-required', () => (_req: Request, _res: ApiV3Response, next: () => void) => next());
+mockRequire('../../../middlewares/admin-required', () => (_req: Request, _res: ApiV3Response, next: () => void) => next());
+
+mockRequire('../../../middlewares/add-activity', {
+  generateAddActivityMiddleware: () => (_req: Request, res: ApiV3Response, next: () => void) => {
+    res.locals = res.locals || {};
+    res.locals.activity = { _id: mockActivityId };
+    next();
+  },
+});
+
+describe('file-upload-setting route', () => {
+  let app: express.Application;
+  let crowiMock: Crowi;
+
+  beforeEach(async() => {
+    // Initialize configManager for each test
+    const s2sMessagingServiceMock = mock<S2sMessagingService>();
+    configManager.setS2sMessagingService(s2sMessagingServiceMock);
+    await configManager.loadConfigs();
+
+    // Mock crowi instance
+    crowiMock = mock<Crowi>({
+      event: vi.fn().mockReturnValue({
+        emit: vi.fn(),
+      }),
+      setUpFileUpload: vi.fn().mockResolvedValue(undefined),
+      fileUploaderSwitchService: {
+        publishUpdatedMessage: vi.fn(),
+      },
+    });
+
+    // Setup express app
+    app = express();
+    app.use(express.json());
+
+    // Mock apiv3 response methods
+    app.use((_req, res, next) => {
+      const apiRes = res as ApiV3Response;
+      apiRes.apiv3 = data => res.json(data);
+      apiRes.apiv3Err = (error, statusCode = 500) => res.status(statusCode).json({ error });
+      next();
+    });
+
+    // Import and mount the actual router using dynamic import
+    const fileUploadSettingModule = await import('./file-upload-setting');
+    const fileUploadSettingRouterFactory = (fileUploadSettingModule as any).default || fileUploadSettingModule;
+    const fileUploadSettingRouter = fileUploadSettingRouterFactory(crowiMock);
+    app.use('/', fileUploadSettingRouter);
+  });
+
+  afterAll(() => {
+    mockRequire.stopAll();
+  });
+
+  it('should update file upload type to local', async() => {
+    const response = await request(app)
+      .put('/')
+      .send({
+        fileUploadType: 'local',
+      })
+      .expect(200);
+
+    expect(response.body.responseParams).toBeDefined();
+    expect(response.body.responseParams.fileUploadType).toBe('local');
+    expect(crowiMock.setUpFileUpload).toHaveBeenCalledWith(true);
+  });
+
+  describe('AWS settings', () => {
+    const setupAwsSecret = async(secret: string) => {
+      await configManager.updateConfigs({
+        'app:fileUploadType': 'aws',
+        'aws:s3SecretAccessKey': toNonBlankString(secret),
+        'aws:s3Region': toNonBlankString('us-west-2'),
+        'aws:s3Bucket': toNonBlankString('existing-bucket'),
+      });
+      await configManager.loadConfigs();
+    };
+
+    it('should preserve existing s3SecretAccessKey when not included in request', async() => {
+      const existingSecret = 'existing-secret-key-12345';
+      await setupAwsSecret(existingSecret);
+
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+
+      const response = await request(app)
+        .put('/')
+        .send({
+          fileUploadType: 'aws',
+          s3Region: 'us-east-1',
+          s3Bucket: 'test-bucket',
+          s3ReferenceFileWithRelayMode: false,
+        })
+        .expect(200);
+
+      await configManager.loadConfigs();
+
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+      expect(response.body.responseParams.fileUploadType).toBe('aws');
+    });
+
+    it('should update s3SecretAccessKey when new value is provided in request', async() => {
+      const existingSecret = 'existing-secret-key-12345';
+      await setupAwsSecret(existingSecret);
+
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+
+      const newSecret = 'new-secret-key-67890';
+      const response = await request(app)
+        .put('/')
+        .send({
+          fileUploadType: 'aws',
+          s3Region: 'us-east-1',
+          s3Bucket: 'test-bucket',
+          s3SecretAccessKey: newSecret,
+          s3ReferenceFileWithRelayMode: false,
+        })
+        .expect(200);
+
+      await configManager.loadConfigs();
+
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(newSecret);
+      expect(response.body.responseParams.fileUploadType).toBe('aws');
+    });
+
+    it('should remove s3SecretAccessKey when empty string is provided in request', async() => {
+      const existingSecret = 'existing-secret-key-12345';
+      await setupAwsSecret(existingSecret);
+
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBe(existingSecret);
+
+      const response = await request(app)
+        .put('/')
+        .send({
+          fileUploadType: 'aws',
+          s3Region: 'us-east-1',
+          s3Bucket: 'test-bucket',
+          s3SecretAccessKey: '',
+          s3ReferenceFileWithRelayMode: false,
+        })
+        .expect(200);
+
+      await configManager.loadConfigs();
+
+      expect(configManager.getConfig('aws:s3SecretAccessKey')).toBeUndefined();
+      expect(response.body.responseParams.fileUploadType).toBe('aws');
+    });
+  });
+
+  describe('GCS settings', () => {
+    const setupGcsSecret = async(apiKeyPath: string) => {
+      await configManager.updateConfigs({
+        'app:fileUploadType': 'gcs',
+        'gcs:apiKeyJsonPath': toNonBlankString(apiKeyPath),
+        'gcs:bucket': toNonBlankString('existing-bucket'),
+      });
+      await configManager.loadConfigs();
+    };
+
+    it('should preserve existing gcsApiKeyJsonPath when not included in request', async() => {
+      const existingApiKeyPath = '/path/to/existing-api-key.json';
+      await setupGcsSecret(existingApiKeyPath);
+
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+
+      const response = await request(app)
+        .put('/')
+        .send({
+          fileUploadType: 'gcs',
+          gcsBucket: 'test-bucket',
+          gcsReferenceFileWithRelayMode: false,
+        })
+        .expect(200);
+
+      await configManager.loadConfigs();
+
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+      expect(response.body.responseParams.fileUploadType).toBe('gcs');
+    });
+
+    it('should update gcsApiKeyJsonPath when new value is provided in request', async() => {
+      const existingApiKeyPath = '/path/to/existing-api-key.json';
+      await setupGcsSecret(existingApiKeyPath);
+
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+
+      const newApiKeyPath = '/path/to/new-api-key.json';
+      const response = await request(app)
+        .put('/')
+        .send({
+          fileUploadType: 'gcs',
+          gcsBucket: 'test-bucket',
+          gcsApiKeyJsonPath: newApiKeyPath,
+          gcsReferenceFileWithRelayMode: false,
+        })
+        .expect(200);
+
+      await configManager.loadConfigs();
+
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(newApiKeyPath);
+      expect(response.body.responseParams.fileUploadType).toBe('gcs');
+    });
+
+    it('should remove gcsApiKeyJsonPath when empty string is provided in request', async() => {
+      const existingApiKeyPath = '/path/to/existing-api-key.json';
+      await setupGcsSecret(existingApiKeyPath);
+
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBe(existingApiKeyPath);
+
+      const response = await request(app)
+        .put('/')
+        .send({
+          fileUploadType: 'gcs',
+          gcsBucket: 'test-bucket',
+          gcsApiKeyJsonPath: '',
+          gcsReferenceFileWithRelayMode: false,
+        })
+        .expect(200);
+
+      await configManager.loadConfigs();
+
+      expect(configManager.getConfig('gcs:apiKeyJsonPath')).toBeUndefined();
+      expect(response.body.responseParams.fileUploadType).toBe('gcs');
+    });
+  });
+
+  describe('Azure settings', () => {
+    const setupAzureSecret = async(secret: string) => {
+      await configManager.updateConfigs({
+        'app:fileUploadType': 'azure',
+        'azure:clientSecret': toNonBlankString(secret),
+        'azure:tenantId': toNonBlankString('existing-tenant-id'),
+        'azure:clientId': toNonBlankString('existing-client-id'),
+        'azure:storageAccountName': toNonBlankString('existingaccount'),
+        'azure:storageContainerName': toNonBlankString('existing-container'),
+      });
+      await configManager.loadConfigs();
+    };
+
+    it('should preserve existing azureClientSecret when not included in request', async() => {
+      const existingSecret = 'existing-azure-secret-12345';
+      await setupAzureSecret(existingSecret);
+
+      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+
+      const response = await request(app)
+        .put('/')
+        .send({
+          fileUploadType: 'azure',
+          azureTenantId: 'new-tenant-id',
+          azureClientId: 'new-client-id',
+          azureStorageAccountName: 'newaccount',
+          azureStorageContainerName: 'new-container',
+          azureReferenceFileWithRelayMode: false,
+        })
+        .expect(200);
+
+      await configManager.loadConfigs();
+
+      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+      expect(response.body.responseParams.fileUploadType).toBe('azure');
+    });
+
+    it('should update azureClientSecret when new value is provided in request', async() => {
+      const existingSecret = 'existing-azure-secret-12345';
+      await setupAzureSecret(existingSecret);
+
+      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+
+      const newSecret = 'new-azure-secret-67890';
+      const response = await request(app)
+        .put('/')
+        .send({
+          fileUploadType: 'azure',
+          azureTenantId: 'new-tenant-id',
+          azureClientId: 'new-client-id',
+          azureStorageAccountName: 'newaccount',
+          azureStorageContainerName: 'new-container',
+          azureClientSecret: newSecret,
+          azureReferenceFileWithRelayMode: false,
+        })
+        .expect(200);
+
+      await configManager.loadConfigs();
+
+      expect(configManager.getConfig('azure:clientSecret')).toBe(newSecret);
+      expect(response.body.responseParams.fileUploadType).toBe('azure');
+    });
+
+    it('should remove azureClientSecret when empty string is provided in request', async() => {
+      const existingSecret = 'existing-azure-secret-12345';
+      await setupAzureSecret(existingSecret);
+
+      expect(configManager.getConfig('azure:clientSecret')).toBe(existingSecret);
+
+      const response = await request(app)
+        .put('/')
+        .send({
+          fileUploadType: 'azure',
+          azureTenantId: 'new-tenant-id',
+          azureClientId: 'new-client-id',
+          azureStorageAccountName: 'newaccount',
+          azureStorageContainerName: 'new-container',
+          azureClientSecret: '',
+          azureReferenceFileWithRelayMode: false,
+        })
+        .expect(200);
+
+      await configManager.loadConfigs();
+
+      expect(configManager.getConfig('azure:clientSecret')).toBeUndefined();
+      expect(response.body.responseParams.fileUploadType).toBe('azure');
+    });
+  });
+});

+ 318 - 0
apps/app/src/server/routes/apiv3/app-settings/file-upload-setting.ts

@@ -0,0 +1,318 @@
+import {
+  toNonBlankString, toNonBlankStringOrUndefined, SCOPE,
+} from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import express from 'express';
+import { body } from 'express-validator';
+
+import { SupportedAction } from '~/interfaces/activity';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { configManager } from '~/server/service/config-manager';
+import { getTranslation } from '~/server/service/i18next';
+import loggerFactory from '~/utils/logger';
+
+import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
+
+const logger = loggerFactory('growi:routes:apiv3:app-settings:file-upload-setting');
+
+const router = express.Router();
+
+type BaseResponseParams = {
+  fileUploadType: string;
+};
+
+type GcsResponseParams = BaseResponseParams & {
+  gcsApiKeyJsonPath?: string;
+  gcsBucket?: string;
+  gcsUploadNamespace?: string;
+  gcsReferenceFileWithRelayMode?: boolean;
+};
+
+type AwsResponseParams = BaseResponseParams & {
+  s3Region?: string;
+  s3CustomEndpoint?: string;
+  s3Bucket?: string;
+  s3AccessKeyId?: string;
+  s3ReferenceFileWithRelayMode?: boolean;
+};
+
+type AzureResponseParams = BaseResponseParams & {
+  azureTenantId?: string;
+  azureClientId?: string;
+  azureClientSecret?: string;
+  azureStorageAccountName?: string;
+  azureStorageContainerName?: string;
+  azureReferenceFileWithRelayMode?: boolean;
+};
+
+type ResponseParams = BaseResponseParams | GcsResponseParams | AwsResponseParams | AzureResponseParams;
+
+const validator = {
+  fileUploadSetting: [
+    body('fileUploadType').isIn(['aws', 'gcs', 'local', 'gridfs', 'azure']),
+    body('gcsApiKeyJsonPath').optional(),
+    body('gcsBucket').optional(),
+    body('gcsUploadNamespace').optional(),
+    body('gcsReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+    body('s3Bucket').optional(),
+    body('s3Region')
+      .optional()
+      .if(value => value !== '' && value != null)
+      .custom(async(value) => {
+        const { t } = await getTranslation();
+        if (!/^[a-z]+-[a-z]+-\d+$/.test(value)) {
+          throw new Error(t('validation.aws_region'));
+        }
+        return true;
+      }),
+    body('s3CustomEndpoint')
+      .optional()
+      .if(value => value !== '' && value != null)
+      .custom(async(value) => {
+        const { t } = await getTranslation();
+        if (!/^(https?:\/\/[^/]+|)$/.test(value)) {
+          throw new Error(t('validation.aws_custom_endpoint'));
+        }
+        return true;
+      }),
+    body('s3AccessKeyId').optional().if(value => value !== '' && value != null).matches(/^[\da-zA-Z]+$/),
+    body('s3SecretAccessKey').optional(),
+    body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+    body('azureTenantId').optional(),
+    body('azureClientId').optional(),
+    body('azureClientSecret').optional(),
+    body('azureStorageAccountName').optional(),
+    body('azureStorageStorageName').optional(),
+    body('azureReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
+  ],
+};
+
+/**
+ * @swagger
+ *
+ *    /app-settings/file-upload-settings:
+ *      put:
+ *        tags: [AppSettings]
+ *        security:
+ *          - cookieAuth: []
+ *        summary: /app-settings/file-upload-setting
+ *        description: Update fileUploadSetting
+ *        requestBody:
+ *          required: true
+ *          content:
+ *            application/json:
+ *              schema:
+ *                $ref: '#/components/schemas/FileUploadSettingParams'
+ *        responses:
+ *          200:
+ *            description: Succeeded to update fileUploadSetting
+ *            content:
+ *              application/json:
+ *                schema:
+ *                  type: object
+ *                  properties:
+ *                    responseParams:
+ *                      type: object
+ *                      $ref: '#/components/schemas/FileUploadSettingParams'
+ */
+/** @param {import('~/server/crowi').default} crowi Crowi instance */
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
+
+  const activityEvent = crowi.event('activity');
+
+  //  eslint-disable-next-line max-len
+  router.put('/', accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
+    loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
+      const { fileUploadType } = req.body;
+
+      if (fileUploadType === 'local' || fileUploadType === 'gridfs') {
+        try {
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+          }, { skipPubsub: true });
+        }
+        catch (err) {
+          const msg = `Error occurred in updating ${fileUploadType} settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
+      }
+
+      if (fileUploadType === 'aws') {
+        try {
+          try {
+            toNonBlankString(req.body.s3Bucket);
+          }
+          catch (err) {
+            throw new Error('S3 Bucket name is required');
+          }
+          try {
+            toNonBlankString(req.body.s3Region);
+          }
+          catch (err) {
+            throw new Error('S3 Region is required');
+          }
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'aws:s3Region': toNonBlankString(req.body.s3Region),
+            'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
+            'aws:referenceFileWithRelayMode': req.body.s3ReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+
+          // Update optional non-secret fields (can be removed if undefined)
+          await configManager.updateConfigs({
+            'aws:s3CustomEndpoint': toNonBlankStringOrUndefined(req.body.s3CustomEndpoint),
+          },
+          {
+            skipPubsub: true,
+            removeIfUndefined: true,
+          });
+
+          // Update secret fields only if explicitly provided in request
+          if (req.body.s3AccessKeyId !== undefined) {
+            await configManager.updateConfigs({
+              'aws:s3AccessKeyId': toNonBlankStringOrUndefined(req.body.s3AccessKeyId),
+            },
+            {
+              skipPubsub: true,
+              removeIfUndefined: true,
+            });
+          }
+
+          if (req.body.s3SecretAccessKey !== undefined) {
+            await configManager.updateConfigs({
+              'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(req.body.s3SecretAccessKey),
+            },
+            {
+              skipPubsub: true,
+              removeIfUndefined: true,
+            });
+          }
+        }
+        catch (err) {
+          const msg = `Error occurred in updating AWS S3 settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
+      }
+
+      if (fileUploadType === 'gcs') {
+        try {
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'gcs:referenceFileWithRelayMode': req.body.gcsReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+
+          // Update optional non-secret fields (can be removed if undefined)
+          await configManager.updateConfigs({
+            'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
+            'gcs:uploadNamespace': toNonBlankStringOrUndefined(req.body.gcsUploadNamespace),
+          },
+          { skipPubsub: true, removeIfUndefined: true });
+
+          // Update secret fields only if explicitly provided in request
+          if (req.body.gcsApiKeyJsonPath !== undefined) {
+            await configManager.updateConfigs({
+              'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(req.body.gcsApiKeyJsonPath),
+            },
+            { skipPubsub: true, removeIfUndefined: true });
+          }
+        }
+        catch (err) {
+          const msg = `Error occurred in updating GCS settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
+      }
+
+      if (fileUploadType === 'azure') {
+        try {
+          await configManager.updateConfigs({
+            'app:fileUploadType': fileUploadType,
+            'azure:referenceFileWithRelayMode': req.body.azureReferenceFileWithRelayMode,
+          },
+          { skipPubsub: true });
+
+          // Update optional non-secret fields (can be removed if undefined)
+          await configManager.updateConfigs({
+            'azure:tenantId': toNonBlankStringOrUndefined(req.body.azureTenantId),
+            'azure:clientId': toNonBlankStringOrUndefined(req.body.azureClientId),
+            'azure:storageAccountName': toNonBlankStringOrUndefined(req.body.azureStorageAccountName),
+            'azure:storageContainerName': toNonBlankStringOrUndefined(req.body.azureStorageContainerName),
+          }, { skipPubsub: true, removeIfUndefined: true });
+
+          // Update secret fields only if explicitly provided in request
+          if (req.body.azureClientSecret !== undefined) {
+            await configManager.updateConfigs({
+              'azure:clientSecret': toNonBlankStringOrUndefined(req.body.azureClientSecret),
+            }, { skipPubsub: true, removeIfUndefined: true });
+          }
+        }
+        catch (err) {
+          const msg = `Error occurred in updating Azure settings: ${err.message}`;
+          logger.error('Error', err);
+          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+        }
+      }
+
+      try {
+        await crowi.setUpFileUpload(true);
+        crowi.fileUploaderSwitchService.publishUpdatedMessage();
+
+        let responseParams: ResponseParams = {
+          fileUploadType: configManager.getConfig('app:fileUploadType'),
+        };
+
+        if (fileUploadType === 'gcs') {
+          responseParams = {
+            fileUploadType: configManager.getConfig('app:fileUploadType'),
+            gcsApiKeyJsonPath: configManager.getConfig('gcs:apiKeyJsonPath'),
+            gcsBucket: configManager.getConfig('gcs:bucket'),
+            gcsUploadNamespace: configManager.getConfig('gcs:uploadNamespace'),
+            gcsReferenceFileWithRelayMode: configManager.getConfig('gcs:referenceFileWithRelayMode'),
+          };
+        }
+
+        if (fileUploadType === 'aws') {
+          responseParams = {
+            fileUploadType: configManager.getConfig('app:fileUploadType'),
+            s3Region: configManager.getConfig('aws:s3Region'),
+            s3CustomEndpoint: configManager.getConfig('aws:s3CustomEndpoint'),
+            s3Bucket: configManager.getConfig('aws:s3Bucket'),
+            s3AccessKeyId: configManager.getConfig('aws:s3AccessKeyId'),
+            s3ReferenceFileWithRelayMode: configManager.getConfig('aws:referenceFileWithRelayMode'),
+          };
+        }
+
+        if (fileUploadType === 'azure') {
+          responseParams = {
+            fileUploadType: configManager.getConfig('app:fileUploadType'),
+            azureTenantId: configManager.getConfig('azure:tenantId'),
+            azureClientId: configManager.getConfig('azure:clientId'),
+            azureClientSecret: configManager.getConfig('azure:clientSecret'),
+            azureStorageAccountName: configManager.getConfig('azure:storageAccountName'),
+            azureStorageContainerName: configManager.getConfig('azure:storageContainerName'),
+            azureReferenceFileWithRelayMode: configManager.getConfig('azure:referenceFileWithRelayMode'),
+          };
+        }
+
+        const parameters = { action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE };
+        activityEvent.emit('update', res.locals.activity._id, parameters);
+        return res.apiv3({ responseParams });
+      }
+      catch (err) {
+        const msg = 'Error occurred in retrieving file upload configurations';
+        logger.error('Error', err);
+        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
+      }
+
+    });
+
+  return router;
+};

+ 15 - 217
apps/app/src/server/routes/apiv3/app-settings.js → apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -1,5 +1,5 @@
 import {
 import {
-  ConfigSource, toNonBlankString, toNonBlankStringOrUndefined, SCOPE,
+  ConfigSource, SCOPE,
 } from '@growi/core/dist/interfaces';
 } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
@@ -12,8 +12,8 @@ import { configManager } from '~/server/service/config-manager';
 import { getTranslation } from '~/server/service/i18next';
 import { getTranslation } from '~/server/service/i18next';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
-import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { generateAddActivityMiddleware } from '../../../middlewares/add-activity';
+import { apiV3FormValidator } from '../../../middlewares/apiv3-form-validator';
 
 
 
 
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
 const logger = loggerFactory('growi:routes:apiv3:app-settings');
@@ -317,9 +317,9 @@ const router = express.Router();
  */
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
 module.exports = (crowi) => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
-  const addActivity = generateAddActivityMiddleware(crowi);
+  const loginRequiredStrictly = require('../../../middlewares/login-required')(crowi);
+  const adminRequired = require('../../../middlewares/admin-required')(crowi);
+  const addActivity = generateAddActivityMiddleware();
 
 
   const activityEvent = crowi.event('activity');
   const activityEvent = crowi.event('activity');
 
 
@@ -349,44 +349,6 @@ module.exports = (crowi) => {
       body('sesAccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('sesAccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
       body('sesSecretAccessKey').trim(),
       body('sesSecretAccessKey').trim(),
     ],
     ],
-    fileUploadSetting: [
-      body('fileUploadType').isIn(['aws', 'gcs', 'local', 'gridfs', 'azure']),
-      body('gcsApiKeyJsonPath').trim(),
-      body('gcsBucket').trim(),
-      body('gcsUploadNamespace').trim(),
-      body('gcsReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
-      body('s3Bucket').trim(),
-      body('s3Region')
-        .trim()
-        .if(value => value !== '')
-        .custom(async(value) => {
-          const { t } = await getTranslation();
-          if (!/^[a-z]+-[a-z]+-\d+$/.test(value)) {
-            throw new Error(t('validation.aws_region'));
-          }
-          return true;
-        }),
-      body('s3CustomEndpoint')
-        .trim()
-        .if(value => value !== '')
-        .custom(async(value) => {
-          const { t } = await getTranslation();
-          if (!/^(https?:\/\/[^/]+|)$/.test(value)) {
-            throw new Error(t('validation.aws_custom_endpoint'));
-          }
-          return true;
-        }),
-      body('s3AccessKeyId').trim().if(value => value !== '').matches(/^[\da-zA-Z]+$/),
-      body('s3SecretAccessKey').trim(),
-      body('s3ReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
-      body('azureTenantId').trim(),
-      body('azureClientId').trim(),
-      body('azureClientSecret').trim(),
-      body('azureStorageAccountName').trim(),
-      body('azureStorageStorageName').trim(),
-      body('azureReferenceFileWithRelayMode').if(value => value != null).isBoolean(),
-
-    ],
     pageBulkExportSettings: [
     pageBulkExportSettings: [
       body('isBulkExportPagesEnabled').isBoolean(),
       body('isBulkExportPagesEnabled').isBoolean(),
       body('bulkExportDownloadExpirationSeconds').isInt(),
       body('bulkExportDownloadExpirationSeconds').isInt(),
@@ -475,8 +437,6 @@ module.exports = (crowi) => {
       envAzureStorageAccountName: configManager.getConfig('azure:storageAccountName', ConfigSource.env),
       envAzureStorageAccountName: configManager.getConfig('azure:storageAccountName', ConfigSource.env),
       envAzureStorageContainerName: configManager.getConfig('azure:storageContainerName', ConfigSource.env),
       envAzureStorageContainerName: configManager.getConfig('azure:storageContainerName', ConfigSource.env),
 
 
-      isEnabledPlugins: configManager.getConfig('plugin:isEnabledPlugins'),
-
       isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
       isMaintenanceMode: configManager.getConfig('app:isMaintenanceMode'),
 
 
       isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
       isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled'),
@@ -652,7 +612,13 @@ module.exports = (crowi) => {
     const smtpUser = configManager.getConfig('mail:smtpUser');
     const smtpUser = configManager.getConfig('mail:smtpUser');
     const smtpPassword = configManager.getConfig('mail:smtpPassword');
     const smtpPassword = configManager.getConfig('mail:smtpPassword');
 
 
-    const option = {
+    // Define the option object with possible 'auth' and 'secure' properties
+    const option: {
+      host: string | undefined;
+      port: string | undefined;
+      auth?: { user: string; pass: string };
+      secure?: boolean;
+    } = {
       host: smtpHost,
       host: smtpHost,
       port: smtpPort,
       port: smtpPort,
     };
     };
@@ -662,7 +628,7 @@ module.exports = (crowi) => {
         pass: smtpPassword,
         pass: smtpPassword,
       };
       };
     }
     }
-    if (option.port === 465) {
+    if (option.port === '465') {
       option.secure = true;
       option.secure = true;
     }
     }
 
 
@@ -844,175 +810,7 @@ module.exports = (crowi) => {
       return res.apiv3({ mailSettingParams });
       return res.apiv3({ mailSettingParams });
     });
     });
 
 
-  /**
-   * @swagger
-   *
-   *    /app-settings/file-upload-settings:
-   *      put:
-   *        tags: [AppSettings]
-   *        security:
-   *          - cookieAuth: []
-   *        summary: /app-settings/file-upload-setting
-   *        description: Update fileUploadSetting
-   *        requestBody:
-   *          required: true
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/FileUploadSettingParams'
-   *        responses:
-   *          200:
-   *            description: Succeeded to update fileUploadSetting
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  type: object
-   *                  properties:
-   *                    responseParams:
-   *                      type: object
-   *                      $ref: '#/components/schemas/FileUploadSettingParams'
-   */
-  //  eslint-disable-next-line max-len
-  router.put('/file-upload-setting', accessTokenParser([SCOPE.WRITE.ADMIN.APP]),
-    loginRequiredStrictly, adminRequired, addActivity, validator.fileUploadSetting, apiV3FormValidator, async(req, res) => {
-      const { fileUploadType } = req.body;
-
-      if (fileUploadType === 'local' || fileUploadType === 'gridfs') {
-        try {
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-          }, { skipPubsub: true });
-        }
-        catch (err) {
-          const msg = `Error occurred in updating ${fileUploadType} settings: ${err.message}`;
-          logger.error('Error', err);
-          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
-        }
-      }
-
-      if (fileUploadType === 'aws') {
-        try {
-          try {
-            toNonBlankString(req.body.s3Bucket);
-          }
-          catch (err) {
-            throw new Error('S3 Bucket name is required');
-          }
-          try {
-            toNonBlankString(req.body.s3Region);
-          }
-          catch (err) {
-            throw new Error('S3 Region is required');
-          }
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-            'aws:s3Region': toNonBlankString(req.body.s3Region),
-            'aws:s3Bucket': toNonBlankString(req.body.s3Bucket),
-            'aws:referenceFileWithRelayMode': req.body.s3ReferenceFileWithRelayMode,
-          },
-          { skipPubsub: true });
-          await configManager.updateConfigs({
-            'aws:s3CustomEndpoint': toNonBlankStringOrUndefined(req.body.s3CustomEndpoint),
-            'aws:s3AccessKeyId': toNonBlankStringOrUndefined(req.body.s3AccessKeyId),
-            'aws:s3SecretAccessKey': toNonBlankStringOrUndefined(req.body.s3SecretAccessKey),
-          },
-          {
-            skipPubsub: true,
-            removeIfUndefined: true,
-          });
-        }
-        catch (err) {
-          const msg = `Error occurred in updating AWS S3 settings: ${err.message}`;
-          logger.error('Error', err);
-          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
-        }
-      }
-
-      if (fileUploadType === 'gcs') {
-        try {
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-            'gcs:referenceFileWithRelayMode': req.body.gcsReferenceFileWithRelayMode,
-          },
-          { skipPubsub: true });
-          await configManager.updateConfigs({
-            'gcs:apiKeyJsonPath': toNonBlankStringOrUndefined(req.body.gcsApiKeyJsonPath),
-            'gcs:bucket': toNonBlankStringOrUndefined(req.body.gcsBucket),
-            'gcs:uploadNamespace': toNonBlankStringOrUndefined(req.body.gcsUploadNamespace),
-          },
-          { skipPubsub: true, removeIfUndefined: true });
-        }
-        catch (err) {
-          const msg = `Error occurred in updating GCS settings: ${err.message}`;
-          logger.error('Error', err);
-          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
-        }
-      }
-
-      if (fileUploadType === 'azure') {
-        try {
-          await configManager.updateConfigs({
-            'app:fileUploadType': fileUploadType,
-            'azure:referenceFileWithRelayMode': req.body.azureReferenceFileWithRelayMode,
-          },
-          { skipPubsub: true });
-          await configManager.updateConfigs({
-            'azure:tenantId': toNonBlankStringOrUndefined(req.body.azureTenantId),
-            'azure:clientId': toNonBlankStringOrUndefined(req.body.azureClientId),
-            'azure:clientSecret': toNonBlankStringOrUndefined(req.body.azureClientSecret),
-            'azure:storageAccountName': toNonBlankStringOrUndefined(req.body.azureStorageAccountName),
-            'azure:storageContainerName': toNonBlankStringOrUndefined(req.body.azureStorageContainerName),
-          }, { skipPubsub: true, removeIfUndefined: true });
-        }
-        catch (err) {
-          const msg = `Error occurred in updating Azure settings: ${err.message}`;
-          logger.error('Error', err);
-          return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
-        }
-      }
-
-      try {
-        await crowi.setUpFileUpload(true);
-        crowi.fileUploaderSwitchService.publishUpdatedMessage();
-
-        const responseParams = {
-          fileUploadType: configManager.getConfig('app:fileUploadType'),
-        };
-
-        if (fileUploadType === 'gcs') {
-          responseParams.gcsApiKeyJsonPath = configManager.getConfig('gcs:apiKeyJsonPath');
-          responseParams.gcsBucket = configManager.getConfig('gcs:bucket');
-          responseParams.gcsUploadNamespace = configManager.getConfig('gcs:uploadNamespace');
-          responseParams.gcsReferenceFileWithRelayMode = configManager.getConfig('gcs:referenceFileWithRelayMode ');
-        }
-
-        if (fileUploadType === 'aws') {
-          responseParams.s3Region = configManager.getConfig('aws:s3Region');
-          responseParams.s3CustomEndpoint = configManager.getConfig('aws:s3CustomEndpoint');
-          responseParams.s3Bucket = configManager.getConfig('aws:s3Bucket');
-          responseParams.s3AccessKeyId = configManager.getConfig('aws:s3AccessKeyId');
-          responseParams.s3ReferenceFileWithRelayMode = configManager.getConfig('aws:referenceFileWithRelayMode');
-        }
-
-        if (fileUploadType === 'azure') {
-          responseParams.azureTenantId = configManager.getConfig('azure:tenantId');
-          responseParams.azureClientId = configManager.getConfig('azure:clientId');
-          responseParams.azureClientSecret = configManager.getConfig('azure:clientSecret');
-          responseParams.azureStorageAccountName = configManager.getConfig('azure:storageAccountName');
-          responseParams.azureStorageContainerName = configManager.getConfig('azure:storageContainerName');
-          responseParams.azureReferenceFileWithRelayMode = configManager.getConfig('azure:referenceFileWithRelayMode');
-        }
-        const parameters = { action: SupportedAction.ACTION_ADMIN_FILE_UPLOAD_CONFIG_UPDATE };
-        activityEvent.emit('update', res.locals.activity._id, parameters);
-        return res.apiv3({ responseParams });
-      }
-      catch (err) {
-        const msg = 'Error occurred in retrieving file upload configurations';
-        logger.error('Error', err);
-        return res.apiv3Err(new ErrorV3(msg, 'update-fileUploadType-failed'));
-      }
-
-    });
+  router.use('/file-upload-setting', require('./file-upload-setting')(crowi));
 
 
 
 
   router.put('/page-bulk-export-settings',
   router.put('/page-bulk-export-settings',

+ 45 - 10
pnpm-lock.yaml

@@ -863,6 +863,9 @@ importers:
       '@types/react-stickynode':
       '@types/react-stickynode':
         specifier: ^4.0.3
         specifier: ^4.0.3
         version: 4.0.3
         version: 4.0.3
+      '@types/supertest':
+        specifier: ^6.0.3
+        version: 6.0.3
       '@types/testing-library__dom':
       '@types/testing-library__dom':
         specifier: ^7.5.0
         specifier: ^7.5.0
         version: 7.5.0
         version: 7.5.0
@@ -1010,6 +1013,9 @@ importers:
       source-map-loader:
       source-map-loader:
         specifier: ^4.0.1
         specifier: ^4.0.1
         version: 4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
         version: 4.0.2(webpack@5.92.1(@swc/core@1.10.7(@swc/helpers@0.5.15)))
+      supertest:
+        specifier: ^7.1.4
+        version: 7.1.4
       swagger2openapi:
       swagger2openapi:
         specifier: ^7.0.8
         specifier: ^7.0.8
         version: 7.0.8(encoding@0.1.13)
         version: 7.0.8(encoding@0.1.13)
@@ -13774,6 +13780,10 @@ packages:
     engines: {node: '>=14.18.0'}
     engines: {node: '>=14.18.0'}
     deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net
     deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net
 
 
+  superagent@10.2.3:
+    resolution: {integrity: sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==}
+    engines: {node: '>=14.18.0'}
+
   superjson@1.13.3:
   superjson@1.13.3:
     resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
     resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
     engines: {node: '>=10'}
     engines: {node: '>=10'}
@@ -13783,6 +13793,10 @@ packages:
     engines: {node: '>=14.18.0'}
     engines: {node: '>=14.18.0'}
     deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net
     deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net
 
 
+  supertest@7.1.4:
+    resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==}
+    engines: {node: '>=14.18.0'}
+
   supports-color@10.0.0:
   supports-color@10.0.0:
     resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==}
     resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==}
     engines: {node: '>=18'}
     engines: {node: '>=18'}
@@ -16606,7 +16620,7 @@ snapshots:
       '@babel/helper-split-export-declaration': 7.24.6
       '@babel/helper-split-export-declaration': 7.24.6
       '@babel/parser': 7.25.6
       '@babel/parser': 7.25.6
       '@babel/types': 7.25.6
       '@babel/types': 7.25.6
-      debug: 4.4.1(supports-color@5.5.0)
+      debug: 4.4.3
       globals: 11.12.0
       globals: 11.12.0
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
@@ -21780,7 +21794,7 @@ snapshots:
 
 
   agent-base@6.0.2:
   agent-base@6.0.2:
     dependencies:
     dependencies:
-      debug: 4.4.1(supports-color@5.5.0)
+      debug: 4.4.3
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
 
 
@@ -24627,7 +24641,7 @@ snapshots:
 
 
   extract-zip@2.0.1:
   extract-zip@2.0.1:
     dependencies:
     dependencies:
-      debug: 4.4.1(supports-color@5.5.0)
+      debug: 4.4.3
       get-stream: 5.2.0
       get-stream: 5.2.0
       yauzl: 2.10.0
       yauzl: 2.10.0
     optionalDependencies:
     optionalDependencies:
@@ -25054,7 +25068,7 @@ snapshots:
     dependencies:
     dependencies:
       basic-ftp: 5.0.5
       basic-ftp: 5.0.5
       data-uri-to-buffer: 6.0.2
       data-uri-to-buffer: 6.0.2
-      debug: 4.4.1(supports-color@5.5.0)
+      debug: 4.4.3
       fs-extra: 11.2.0
       fs-extra: 11.2.0
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
@@ -26077,7 +26091,7 @@ snapshots:
 
 
   istanbul-lib-source-maps@4.0.1:
   istanbul-lib-source-maps@4.0.1:
     dependencies:
     dependencies:
-      debug: 4.4.1(supports-color@5.5.0)
+      debug: 4.4.3
       istanbul-lib-coverage: 3.2.2
       istanbul-lib-coverage: 3.2.2
       source-map: 0.6.1
       source-map: 0.6.1
     transitivePeerDependencies:
     transitivePeerDependencies:
@@ -28553,7 +28567,7 @@ snapshots:
     dependencies:
     dependencies:
       '@tootallnate/quickjs-emscripten': 0.23.0
       '@tootallnate/quickjs-emscripten': 0.23.0
       agent-base: 7.1.4
       agent-base: 7.1.4
-      debug: 4.4.1(supports-color@5.5.0)
+      debug: 4.4.3
       get-uri: 6.0.3
       get-uri: 6.0.3
       http-proxy-agent: 7.0.2
       http-proxy-agent: 7.0.2
       https-proxy-agent: 7.0.6
       https-proxy-agent: 7.0.6
@@ -28999,7 +29013,7 @@ snapshots:
   proxy-agent@6.4.0:
   proxy-agent@6.4.0:
     dependencies:
     dependencies:
       agent-base: 7.1.4
       agent-base: 7.1.4
-      debug: 4.4.1(supports-color@5.5.0)
+      debug: 4.4.3
       http-proxy-agent: 7.0.2
       http-proxy-agent: 7.0.2
       https-proxy-agent: 7.0.6
       https-proxy-agent: 7.0.6
       lru-cache: 7.18.3
       lru-cache: 7.18.3
@@ -29804,7 +29818,7 @@ snapshots:
 
 
   require-in-the-middle@7.4.0:
   require-in-the-middle@7.4.0:
     dependencies:
     dependencies:
-      debug: 4.4.1(supports-color@5.5.0)
+      debug: 4.4.3
       module-details-from-path: 1.0.3
       module-details-from-path: 1.0.3
       resolve: 1.22.8
       resolve: 1.22.8
     transitivePeerDependencies:
     transitivePeerDependencies:
@@ -30340,7 +30354,7 @@ snapshots:
   socks-proxy-agent@7.0.0:
   socks-proxy-agent@7.0.0:
     dependencies:
     dependencies:
       agent-base: 6.0.2
       agent-base: 6.0.2
-      debug: 4.4.1(supports-color@5.5.0)
+      debug: 4.4.3
       socks: 2.8.3
       socks: 2.8.3
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
@@ -30348,7 +30362,7 @@ snapshots:
   socks-proxy-agent@8.0.4:
   socks-proxy-agent@8.0.4:
     dependencies:
     dependencies:
       agent-base: 7.1.4
       agent-base: 7.1.4
-      debug: 4.4.1(supports-color@5.5.0)
+      debug: 4.4.3
       socks: 2.8.3
       socks: 2.8.3
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
@@ -30778,6 +30792,20 @@ snapshots:
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
 
 
+  superagent@10.2.3:
+    dependencies:
+      component-emitter: 1.3.1
+      cookiejar: 2.1.4
+      debug: 4.4.3
+      fast-safe-stringify: 2.1.1
+      form-data: 4.0.4
+      formidable: 3.5.4
+      methods: 1.1.2
+      mime: 2.6.0
+      qs: 6.13.0
+    transitivePeerDependencies:
+      - supports-color
+
   superjson@1.13.3:
   superjson@1.13.3:
     dependencies:
     dependencies:
       copy-anything: 3.0.5
       copy-anything: 3.0.5
@@ -30789,6 +30817,13 @@ snapshots:
     transitivePeerDependencies:
     transitivePeerDependencies:
       - supports-color
       - supports-color
 
 
+  supertest@7.1.4:
+    dependencies:
+      methods: 1.1.2
+      superagent: 10.2.3
+    transitivePeerDependencies:
+      - supports-color
+
   supports-color@10.0.0: {}
   supports-color@10.0.0: {}
 
 
   supports-color@2.0.0: {}
   supports-color@2.0.0: {}