diff --git a/src/features/authFiles/hooks/useAuthFilesData.ts b/src/features/authFiles/hooks/useAuthFilesData.ts index 0a9cc73..ccf441a 100644 --- a/src/features/authFiles/hooks/useAuthFilesData.ts +++ b/src/features/authFiles/hooks/useAuthFilesData.ts @@ -117,6 +117,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles setSelectedFiles(new Set()); }, []); + const applyDeletedFiles = useCallback((names: string[]) => { + const deletedNames = Array.from( + new Set( + names + .map((name) => name.trim()) + .filter(Boolean) + ) + ); + if (deletedNames.length === 0) return; + + const deletedSet = new Set(deletedNames); + setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name))); + setSelectedFiles((prev) => { + if (prev.size === 0) return prev; + let changed = false; + const next = new Set(); + prev.forEach((name) => { + if (deletedSet.has(name)) { + changed = true; + } else { + next.add(name); + } + }); + return changed ? next : prev; + }); + }, []); + useEffect(() => { if (selectedFiles.size === 0) return; const existingNames = new Set(files.map((file) => file.name)); @@ -190,36 +217,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles } setUploading(true); - let successCount = 0; - const failed: { name: string; message: string }[] = []; + try { + const result = await authFilesApi.uploadFiles(validFiles); + const successCount = result.uploaded; - for (const file of validFiles) { - try { - await authFilesApi.upload(file); - successCount++; - } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : 'Unknown error'; - failed.push({ name: file.name, message: errorMessage }); + if (successCount > 0) { + const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : ''; + showNotification( + `${t('auth_files.upload_success')}${suffix}`, + result.failed.length ? 'warning' : 'success' + ); + await loadFiles(); + await refreshKeyStats(); } - } - if (successCount > 0) { - const suffix = validFiles.length > 1 ? ` (${successCount}/${validFiles.length})` : ''; - showNotification( - `${t('auth_files.upload_success')}${suffix}`, - failed.length ? 'warning' : 'success' - ); - await loadFiles(); - await refreshKeyStats(); + if (result.failed.length > 0) { + const details = result.failed + .map((item) => `${item.name}: ${item.error}`) + .join('; '); + showNotification(`${t('notification.upload_failed')}: ${details}`, 'error'); + } + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + showNotification(`${t('notification.upload_failed')}: ${errorMessage}`, 'error'); + } finally { + setUploading(false); + event.target.value = ''; } - - if (failed.length > 0) { - const details = failed.map((item) => `${item.name}: ${item.message}`).join('; '); - showNotification(`${t('notification.upload_failed')}: ${details}`, 'error'); - } - - setUploading(false); - event.target.value = ''; }, [loadFiles, refreshKeyStats, showNotification, t] ); @@ -234,15 +258,9 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles onConfirm: async () => { setDeleting(name); try { - await authFilesApi.deleteFile(name); + const result = await authFilesApi.deleteFile(name); showNotification(t('auth_files.delete_success'), 'success'); - setFiles((prev) => prev.filter((item) => item.name !== name)); - setSelectedFiles((prev) => { - if (!prev.has(name)) return prev; - const next = new Set(prev); - next.delete(name); - return next; - }); + applyDeletedFiles(result.files.length > 0 ? result.files : [name]); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error'); @@ -252,7 +270,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles }, }); }, - [showConfirmation, showNotification, t] + [applyDeletedFiles, showConfirmation, showNotification, t] ); const handleDeleteAll = useCallback( @@ -301,35 +319,13 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles return; } - let success = 0; - let failed = 0; - const deletedNames: string[] = []; + const result = await authFilesApi.deleteFiles( + filesToDelete.map((file) => file.name) + ); + const success = result.deleted; + const failed = result.failed.length; - for (const file of filesToDelete) { - try { - await authFilesApi.deleteFile(file.name); - success++; - deletedNames.push(file.name); - } catch { - failed++; - } - } - - setFiles((prev) => prev.filter((f) => !deletedNames.includes(f.name))); - setSelectedFiles((prev) => { - if (prev.size === 0) return prev; - const deletedSet = new Set(deletedNames); - let changed = false; - const next = new Set(); - prev.forEach((name) => { - if (deletedSet.has(name)) { - changed = true; - } else { - next.add(name); - } - }); - return changed ? next : prev; - }); + applyDeletedFiles(result.files); if (failed === 0 && isProblemOnly) { showNotification( @@ -380,7 +376,7 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles }, }); }, - [deselectAll, files, showConfirmation, showNotification, t] + [applyDeletedFiles, deselectAll, files, showConfirmation, showNotification, t] ); const handleDownload = useCallback( @@ -579,59 +575,33 @@ export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFiles variant: 'danger', confirmText: t('common.confirm'), onConfirm: async () => { - const results = await Promise.allSettled( - uniqueNames.map((name) => authFilesApi.deleteFile(name)) - ); + try { + const result = await authFilesApi.deleteFiles(uniqueNames); + applyDeletedFiles(result.files); - const deleted: string[] = []; - let failCount = 0; - results.forEach((result, index) => { - if (result.status === 'fulfilled') { - deleted.push(uniqueNames[index]); + if (result.failed.length === 0) { + showNotification( + `${t('auth_files.delete_all_success')} (${result.deleted})`, + 'success' + ); } else { - failCount++; + showNotification( + t('auth_files.delete_filtered_partial', { + success: result.deleted, + failed: result.failed.length, + type: t('auth_files.filter_all'), + }), + 'warning' + ); } - }); - - if (deleted.length > 0) { - const deletedSet = new Set(deleted); - setFiles((prev) => prev.filter((file) => !deletedSet.has(file.name))); - } - - setSelectedFiles((prev) => { - if (prev.size === 0) return prev; - const deletedSet = new Set(deleted); - let changed = false; - const next = new Set(); - prev.forEach((name) => { - if (deletedSet.has(name)) { - changed = true; - } else { - next.add(name); - } - }); - return changed ? next : prev; - }); - - if (failCount === 0) { - showNotification( - `${t('auth_files.delete_all_success')} (${deleted.length})`, - 'success' - ); - } else { - showNotification( - t('auth_files.delete_filtered_partial', { - success: deleted.length, - failed: failCount, - type: t('auth_files.filter_all'), - }), - 'warning' - ); + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : ''; + showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error'); } }, }); }, - [showConfirmation, showNotification, t] + [applyDeletedFiles, showConfirmation, showNotification, t] ); return { diff --git a/src/services/api/authFiles.ts b/src/services/api/authFiles.ts index 82cb350..5cce3d0 100644 --- a/src/services/api/authFiles.ts +++ b/src/services/api/authFiles.ts @@ -9,6 +9,31 @@ import type { OAuthModelAliasEntry } from '@/types'; type StatusError = { status?: number }; type AuthFileStatusResponse = { status: string; disabled: boolean }; type AuthFileEntry = AuthFilesResponse['files'][number]; +type AuthFileBatchFailure = { name: string; error: string }; +type AuthFileBatchUploadResponse = { + status?: string; + uploaded?: number; + files?: unknown; + failed?: unknown; +}; +type AuthFileBatchDeleteResponse = { + status?: string; + deleted?: number; + files?: unknown; + failed?: unknown; +}; +type AuthFileBatchUploadResult = { + status: string; + uploaded: number; + files: string[]; + failed: AuthFileBatchFailure[]; +}; +type AuthFileBatchDeleteResult = { + status: string; + deleted: number; + files: string[]; + failed: AuthFileBatchFailure[]; +}; export const AUTH_FILE_INVALID_JSON_OBJECT_ERROR = 'AUTH_FILE_INVALID_JSON_OBJECT'; @@ -18,6 +43,129 @@ const getStatusCode = (err: unknown): number | undefined => { return undefined; }; +const normalizeRequestedAuthFileNames = (names: string[]): string[] => { + const seen = new Set(); + const normalized: string[] = []; + + names.forEach((name) => { + const trimmed = String(name ?? '').trim(); + if (!trimmed || seen.has(trimmed)) return; + seen.add(trimmed); + normalized.push(trimmed); + }); + + return normalized; +}; + +const normalizeBatchFileNames = (value: unknown): string[] => { + if (!Array.isArray(value)) return []; + return normalizeRequestedAuthFileNames(value.map((item) => String(item ?? ''))); +}; + +const normalizeBatchFailures = (value: unknown): AuthFileBatchFailure[] => { + if (!Array.isArray(value)) return []; + + return value.reduce((result, item) => { + if (!item || typeof item !== 'object') return result; + const entry = item as Record; + const name = String(entry.name ?? '').trim(); + const error = + typeof entry.error === 'string' + ? entry.error.trim() + : typeof entry.message === 'string' + ? entry.message.trim() + : ''; + + if (!name && !error) return result; + result.push({ name, error: error || 'Unknown error' }); + return result; + }, []); +}; + +const deriveSuccessfulFileNames = (requestedNames: string[], failed: AuthFileBatchFailure[]): string[] => { + const failedNames = new Set( + failed + .map((entry) => entry.name.trim()) + .filter(Boolean) + ); + + if (failedNames.size === 0) { + return [...requestedNames]; + } + + return requestedNames.filter((name) => !failedNames.has(name)); +}; + +const normalizeBatchUploadResponse = ( + payload: AuthFileBatchUploadResponse | undefined, + requestedNames: string[] +): AuthFileBatchUploadResult => { + const failed = normalizeBatchFailures(payload?.failed); + const uploadedFilesFromPayload = normalizeBatchFileNames(payload?.files); + const uploaded = + typeof payload?.uploaded === 'number' + ? payload.uploaded + : uploadedFilesFromPayload.length > 0 + ? uploadedFilesFromPayload.length + : requestedNames.length === 1 && failed.length === 0 + ? 1 + : 0; + + let uploadedFiles = uploadedFilesFromPayload; + if (uploadedFiles.length === 0 && uploaded > 0) { + if (failed.length === 0 && uploaded === requestedNames.length) { + uploadedFiles = [...requestedNames]; + } else { + const derivedNames = deriveSuccessfulFileNames(requestedNames, failed); + if (derivedNames.length === uploaded) { + uploadedFiles = derivedNames; + } + } + } + + return { + status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok', + uploaded, + files: uploadedFiles, + failed, + }; +}; + +const normalizeBatchDeleteResponse = ( + payload: AuthFileBatchDeleteResponse | undefined, + requestedNames: string[] +): AuthFileBatchDeleteResult => { + const failed = normalizeBatchFailures(payload?.failed); + const deletedFilesFromPayload = normalizeBatchFileNames(payload?.files); + const deleted = + typeof payload?.deleted === 'number' + ? payload.deleted + : deletedFilesFromPayload.length > 0 + ? deletedFilesFromPayload.length + : requestedNames.length === 1 && failed.length === 0 + ? 1 + : 0; + + let deletedFiles = deletedFilesFromPayload; + if (deletedFiles.length === 0 && deleted > 0) { + if (failed.length === 0 && deleted === requestedNames.length) { + deletedFiles = [...requestedNames]; + } else { + const derivedNames = deriveSuccessfulFileNames(requestedNames, failed); + if (derivedNames.length === deleted) { + deletedFiles = derivedNames; + } + } + } + + return { + status: typeof payload?.status === 'string' ? payload.status : failed.length > 0 ? 'partial' : 'ok', + deleted, + files: deletedFiles, + failed, + }; +}; + const readTextField = (entry: AuthFileEntry, key: string): string => { const value = entry[key]; return typeof value === 'string' ? value.trim() : ''; @@ -252,13 +400,35 @@ export const authFilesApi = { setStatus: (name: string, disabled: boolean) => apiClient.patch('/auth-files/status', { name, disabled }), - upload: (file: File) => { + uploadFiles: async (files: File[]): Promise => { + const requestedNames = files.map((file) => file.name); + if (requestedNames.length === 0) { + return { status: 'ok', uploaded: 0, files: [], failed: [] }; + } + const formData = new FormData(); - formData.append('file', file, file.name); - return apiClient.postForm('/auth-files', formData); + files.forEach((file) => { + formData.append('file', file, file.name); + }); + const payload = await apiClient.postForm('/auth-files', formData); + return normalizeBatchUploadResponse(payload, requestedNames); }, - deleteFile: (name: string) => apiClient.delete(`/auth-files?name=${encodeURIComponent(name)}`), + upload: (file: File) => authFilesApi.uploadFiles([file]), + + deleteFiles: async (names: string[]): Promise => { + const requestedNames = normalizeRequestedAuthFileNames(names); + if (requestedNames.length === 0) { + return { status: 'ok', deleted: 0, files: [], failed: [] }; + } + + const payload = await apiClient.delete('/auth-files', { + data: { names: requestedNames }, + }); + return normalizeBatchDeleteResponse(payload, requestedNames); + }, + + deleteFile: (name: string) => authFilesApi.deleteFiles([name]), deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }),