Files
Cli-Proxy-API-Management-Ce…/src/features/authFiles/hooks/useAuthFilesData.ts
T

635 lines
21 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type ChangeEvent, type RefObject } from 'react';
import { useTranslation } from 'react-i18next';
import { authFilesApi } from '@/services/api';
import { apiClient } from '@/services/api/client';
import { useNotificationStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { formatFileSize } from '@/utils/format';
import { MAX_AUTH_FILE_SIZE } from '@/utils/constants';
import { downloadBlob } from '@/utils/download';
import {
getTypeLabel,
hasAuthFileStatusMessage,
isRuntimeOnlyAuthFile,
} from '@/features/authFiles/constants';
type DeleteAllOptions = {
filter: string;
problemOnly: boolean;
onResetFilterToAll: () => void;
onResetProblemOnly: () => void;
};
export type UseAuthFilesDataResult = {
files: AuthFileItem[];
selectedFiles: Set<string>;
selectionCount: number;
loading: boolean;
error: string;
uploading: boolean;
deleting: string | null;
deletingAll: boolean;
statusUpdating: Record<string, boolean>;
batchStatusUpdating: boolean;
fileInputRef: RefObject<HTMLInputElement | null>;
loadFiles: () => Promise<void>;
handleUploadClick: () => void;
handleFileChange: (event: ChangeEvent<HTMLInputElement>) => Promise<void>;
handleDelete: (name: string) => void;
handleDeleteAll: (options: DeleteAllOptions) => void;
handleDownload: (name: string) => Promise<void>;
handleStatusToggle: (item: AuthFileItem, enabled: boolean) => Promise<void>;
toggleSelect: (name: string) => void;
selectAllVisible: (visibleFiles: AuthFileItem[]) => void;
invertVisibleSelection: (visibleFiles: AuthFileItem[]) => void;
deselectAll: () => void;
batchDownload: (names: string[]) => Promise<void>;
batchSetStatus: (names: string[], enabled: boolean) => Promise<void>;
batchDelete: (names: string[]) => void;
};
export type UseAuthFilesDataOptions = {
refreshKeyStats: () => Promise<void>;
};
export function useAuthFilesData(options: UseAuthFilesDataOptions): UseAuthFilesDataResult {
const { refreshKeyStats } = options;
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [deletingAll, setDeletingAll] = useState(false);
const [statusUpdating, setStatusUpdating] = useState<Record<string, boolean>>({});
const [batchStatusUpdating, setBatchStatusUpdating] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const fileInputRef = useRef<HTMLInputElement | null>(null);
const batchStatusPendingRef = useRef(false);
const selectionCount = selectedFiles.size;
const toggleSelect = useCallback((name: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
}, []);
const selectAllVisible = useCallback((visibleFiles: AuthFileItem[]) => {
const nextSelected = visibleFiles
.filter((file) => !isRuntimeOnlyAuthFile(file))
.map((file) => file.name);
if (nextSelected.length === 0) return;
setSelectedFiles((prev) => {
const next = new Set(prev);
nextSelected.forEach((name) => next.add(name));
return next;
});
}, []);
const invertVisibleSelection = useCallback((visibleFiles: AuthFileItem[]) => {
const visibleNames = visibleFiles
.filter((file) => !isRuntimeOnlyAuthFile(file))
.map((file) => file.name);
if (visibleNames.length === 0) return;
setSelectedFiles((prev) => {
const next = new Set(prev);
visibleNames.forEach((name) => {
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
});
return next;
});
}, []);
const deselectAll = useCallback(() => {
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<string>();
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));
setSelectedFiles((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((name) => {
if (existingNames.has(name)) {
next.add(name);
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [files, selectedFiles.size]);
const loadFiles = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await authFilesApi.list();
setFiles(data?.files || []);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : t('notification.refresh_failed');
setError(errorMessage);
} finally {
setLoading(false);
}
}, [t]);
const handleUploadClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) return;
const filesToUpload = Array.from(fileList);
const validFiles: File[] = [];
const invalidFiles: string[] = [];
const oversizedFiles: string[] = [];
filesToUpload.forEach((file) => {
if (!file.name.endsWith('.json')) {
invalidFiles.push(file.name);
return;
}
if (file.size > MAX_AUTH_FILE_SIZE) {
oversizedFiles.push(file.name);
return;
}
validFiles.push(file);
});
if (invalidFiles.length > 0) {
showNotification(t('auth_files.upload_error_json'), 'error');
}
if (oversizedFiles.length > 0) {
showNotification(
t('auth_files.upload_error_size', { maxSize: formatFileSize(MAX_AUTH_FILE_SIZE) }),
'error'
);
}
if (validFiles.length === 0) {
event.target.value = '';
return;
}
setUploading(true);
try {
const result = await authFilesApi.uploadFiles(validFiles);
const successCount = result.uploaded;
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 (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 = '';
}
},
[loadFiles, refreshKeyStats, showNotification, t]
);
const handleDelete = useCallback(
(name: string) => {
showConfirmation({
title: t('auth_files.delete_title', { defaultValue: 'Delete File' }),
message: `${t('auth_files.delete_confirm')} "${name}" ?`,
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setDeleting(name);
try {
const result = await authFilesApi.deleteFile(name);
showNotification(t('auth_files.delete_success'), 'success');
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');
} finally {
setDeleting(null);
}
},
});
},
[applyDeletedFiles, showConfirmation, showNotification, t]
);
const handleDeleteAll = useCallback(
(deleteAllOptions: DeleteAllOptions) => {
const { filter, problemOnly, onResetFilterToAll, onResetProblemOnly } = deleteAllOptions;
const isFiltered = filter !== 'all';
const isProblemOnly = problemOnly === true;
const typeLabel = isFiltered ? getTypeLabel(t, filter) : t('auth_files.filter_all');
const confirmMessage = isProblemOnly
? isFiltered
? t('auth_files.delete_problem_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_problem_confirm')
: isFiltered
? t('auth_files.delete_filtered_confirm', { type: typeLabel })
: t('auth_files.delete_all_confirm');
showConfirmation({
title: t('auth_files.delete_all_title', { defaultValue: 'Delete All Files' }),
message: confirmMessage,
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setDeletingAll(true);
try {
if (!isFiltered && !isProblemOnly) {
await authFilesApi.deleteAll();
showNotification(t('auth_files.delete_all_success'), 'success');
setFiles((prev) => prev.filter((file) => isRuntimeOnlyAuthFile(file)));
deselectAll();
} else {
const filesToDelete = files.filter((file) => {
if (isRuntimeOnlyAuthFile(file)) return false;
if (isFiltered && file.type !== filter) return false;
if (isProblemOnly && !hasAuthFileStatusMessage(file)) return false;
return true;
});
if (filesToDelete.length === 0) {
const emptyMessage = isProblemOnly
? isFiltered
? t('auth_files.delete_problem_filtered_none', { type: typeLabel })
: t('auth_files.delete_problem_none')
: t('auth_files.delete_filtered_none', { type: typeLabel });
showNotification(emptyMessage, 'info');
setDeletingAll(false);
return;
}
const result = await authFilesApi.deleteFiles(
filesToDelete.map((file) => file.name)
);
const success = result.deleted;
const failed = result.failed.length;
applyDeletedFiles(result.files);
if (failed === 0 && isProblemOnly) {
showNotification(
isFiltered
? t('auth_files.delete_problem_filtered_success', {
count: success,
type: typeLabel,
})
: t('auth_files.delete_problem_success', { count: success }),
'success'
);
} else if (failed === 0) {
showNotification(
t('auth_files.delete_filtered_success', { count: success, type: typeLabel }),
'success'
);
} else if (isProblemOnly) {
showNotification(
isFiltered
? t('auth_files.delete_problem_filtered_partial', {
success,
failed,
type: typeLabel,
})
: t('auth_files.delete_problem_partial', { success, failed }),
'warning'
);
} else {
showNotification(
t('auth_files.delete_filtered_partial', { success, failed, type: typeLabel }),
'warning'
);
}
if (isFiltered) {
onResetFilterToAll();
}
if (isProblemOnly) {
onResetProblemOnly();
}
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
} finally {
setDeletingAll(false);
}
},
});
},
[applyDeletedFiles, deselectAll, files, showConfirmation, showNotification, t]
);
const handleDownload = useCallback(
async (name: string) => {
try {
const response = await apiClient.getRaw(
`/auth-files/download?name=${encodeURIComponent(name)}`,
{ responseType: 'blob' }
);
const blob = new Blob([response.data]);
downloadBlob({ filename: name, blob });
showNotification(t('auth_files.download_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.download_failed')}: ${errorMessage}`, 'error');
}
},
[showNotification, t]
);
const handleStatusToggle = useCallback(
async (item: AuthFileItem, enabled: boolean) => {
const name = item.name;
const nextDisabled = !enabled;
const previousDisabled = item.disabled === true;
setStatusUpdating((prev) => ({ ...prev, [name]: true }));
setFiles((prev) => prev.map((f) => (f.name === name ? { ...f, disabled: nextDisabled } : f)));
try {
const res = await authFilesApi.setStatus(name, nextDisabled);
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: res.disabled } : f))
);
showNotification(
enabled
? t('auth_files.status_enabled_success', { name })
: t('auth_files.status_disabled_success', { name }),
'success'
);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
setFiles((prev) =>
prev.map((f) => (f.name === name ? { ...f, disabled: previousDisabled } : f))
);
showNotification(`${t('notification.update_failed')}: ${errorMessage}`, 'error');
} finally {
setStatusUpdating((prev) => {
if (!prev[name]) return prev;
const next = { ...prev };
delete next[name];
return next;
});
}
},
[showNotification, t]
);
const batchSetStatus = useCallback(
async (names: string[], enabled: boolean) => {
if (batchStatusPendingRef.current) return;
const uniqueNames = Array.from(new Set(names));
if (uniqueNames.length === 0) return;
if (uniqueNames.some((name) => statusUpdating[name] === true)) return;
const originalDisabled = new Map(
files
.filter((file) => uniqueNames.includes(file.name))
.map((file) => [file.name, file.disabled === true])
);
const targetNames = new Set(originalDisabled.keys());
const targetNameList = Array.from(targetNames);
if (targetNameList.length === 0) return;
const nextDisabled = !enabled;
batchStatusPendingRef.current = true;
setBatchStatusUpdating(true);
setStatusUpdating((prev) => {
const next = { ...prev };
targetNameList.forEach((name) => {
next[name] = true;
});
return next;
});
setFiles((prev) =>
prev.map((file) =>
targetNames.has(file.name) ? { ...file, disabled: nextDisabled } : file
)
);
try {
const results = await Promise.allSettled(
targetNameList.map((name) => authFilesApi.setStatus(name, nextDisabled))
);
let successCount = 0;
let failCount = 0;
const failedNames = new Set<string>();
const confirmedDisabled = new Map<string, boolean>();
results.forEach((result, index) => {
const name = targetNameList[index];
if (result.status === 'fulfilled') {
successCount++;
confirmedDisabled.set(name, result.value.disabled);
} else {
failCount++;
failedNames.add(name);
}
});
setFiles((prev) =>
prev.map((file) => {
if (failedNames.has(file.name)) {
return { ...file, disabled: originalDisabled.get(file.name) === true };
}
if (confirmedDisabled.has(file.name)) {
return { ...file, disabled: confirmedDisabled.get(file.name) };
}
return file;
})
);
if (failCount === 0) {
showNotification(t('auth_files.batch_status_success', { count: successCount }), 'success');
} else {
showNotification(
t('auth_files.batch_status_partial', { success: successCount, failed: failCount }),
'warning'
);
}
deselectAll();
} finally {
batchStatusPendingRef.current = false;
setBatchStatusUpdating(false);
setStatusUpdating((prev) => {
const next = { ...prev };
targetNameList.forEach((name) => {
delete next[name];
});
return next;
});
}
},
[deselectAll, files, showNotification, statusUpdating, t]
);
const batchDownload = useCallback(
async (names: string[]) => {
const uniqueNames = Array.from(new Set(names));
if (uniqueNames.length === 0) return;
let successCount = 0;
let failCount = 0;
for (const name of uniqueNames) {
try {
const response = await apiClient.getRaw(
`/auth-files/download?name=${encodeURIComponent(name)}`,
{ responseType: 'blob' }
);
const blob = new Blob([response.data]);
downloadBlob({ filename: name, blob });
successCount++;
} catch {
failCount++;
}
}
if (failCount === 0) {
showNotification(
t('auth_files.batch_download_success', { count: successCount }),
'success'
);
} else {
showNotification(
t('auth_files.batch_download_partial', { success: successCount, failed: failCount }),
'warning'
);
}
},
[showNotification, t]
);
const batchDelete = useCallback(
(names: string[]) => {
const uniqueNames = Array.from(new Set(names));
if (uniqueNames.length === 0) return;
showConfirmation({
title: t('auth_files.batch_delete_title'),
message: t('auth_files.batch_delete_confirm', { count: uniqueNames.length }),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
const result = await authFilesApi.deleteFiles(uniqueNames);
applyDeletedFiles(result.files);
if (result.failed.length === 0) {
showNotification(
`${t('auth_files.delete_all_success')} (${result.deleted})`,
'success'
);
} else {
showNotification(
t('auth_files.delete_filtered_partial', {
success: result.deleted,
failed: result.failed.length,
type: t('auth_files.filter_all'),
}),
'warning'
);
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('notification.delete_failed')}: ${errorMessage}`, 'error');
}
},
});
},
[applyDeletedFiles, showConfirmation, showNotification, t]
);
return {
files,
selectedFiles,
selectionCount,
loading,
error,
uploading,
deleting,
deletingAll,
statusUpdating,
batchStatusUpdating,
fileInputRef,
loadFiles,
handleUploadClick,
handleFileChange,
handleDelete,
handleDeleteAll,
handleDownload,
handleStatusToggle,
toggleSelect,
selectAllVisible,
invertVisibleSelection,
deselectAll,
batchDownload,
batchSetStatus,
batchDelete,
};
}