diff --git a/ui/src/store/client.model.ts b/ui/src/store/client.model.ts index 2a67ea60..a2ca3ab6 100644 --- a/ui/src/store/client.model.ts +++ b/ui/src/store/client.model.ts @@ -25,6 +25,8 @@ export interface FileManagerClipboard { operation: 'copy' | 'cut' items: FileItem[] sourceFolder?: string + /** Tenant the items were copied/cut from. Paste is rejected across tenants. */ + sourceTenantId?: string } export interface FileManagerState { diff --git a/ui/src/views/admin/files/FileManager.tsx b/ui/src/views/admin/files/FileManager.tsx index 6caca434..2dd6115e 100644 --- a/ui/src/views/admin/files/FileManager.tsx +++ b/ui/src/views/admin/files/FileManager.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, type MouseEvent } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef, type MouseEvent } from 'react' import { Helmet } from 'react-helmet' import { Button, Input, Select, toast, Notification, Spinner } from '@/components/ui' import { useStoreActions, useStoreState } from '@/store' @@ -43,7 +43,48 @@ import { APP_NAME } from '@/constants/app.constant' const { VITE_CDN_URL } = import.meta.env -// Select options for sorting +const ROOT_BREADCRUMB: BreadcrumbItem = { name: 'Files', path: '', id: undefined } + +const TOAST_PLACEMENT = { placement: 'bottom-end' } as const + +/** Navigation state kept as a single value so tenant + folder always change atomically. */ +interface NavigationState { + tenantId?: string + folderId?: string +} + +/** + * Builds a CDN url for an item. Every path segment is encoded and traversal + * segments are rejected so a crafted file/folder name cannot escape the + * tenant root of the CDN. + */ +const buildDownloadUrl = (item: FileItemType, fallbackTenantId?: string): string | undefined => { + // Backend encodes the relative path as id by replacing "/" with "|". + const relativePath = item.path || item.id.replace(/\|/g, '/') + const segments = relativePath.split('/').filter(Boolean) + + if (segments.length === 0 || segments.some((segment) => segment === '.' || segment === '..')) { + return undefined + } + + const tenantId = item.tenantId || fallbackTenantId + const tenantSegment = tenantId ? `tenants/${encodeURIComponent(tenantId)}` : 'host' + + return `${VITE_CDN_URL}/${tenantSegment}/${segments.map(encodeURIComponent).join('/')}` +} + +/** Keyboard shortcuts must not hijack typing inside form controls. */ +const isEditableTarget = (target: EventTarget | null): boolean => { + const element = target as HTMLElement | null + if (!element || typeof element.tagName !== 'string') return false + const tagName = element.tagName.toLowerCase() + return ( + tagName === 'input' || + tagName === 'textarea' || + tagName === 'select' || + element.isContentEditable === true + ) +} const FileManager = () => { const { translate } = useLocalization() @@ -59,14 +100,10 @@ const FileManager = () => { ) const isHostContext = !authTenantId - // State + // Data state const [loading, setLoading] = useState(true) const [items, setItems] = useState([]) - const [filteredItems, setFilteredItems] = useState([]) - const [currentFolderId, setCurrentFolderId] = useState() - const [breadcrumbItems, setBreadcrumbItems] = useState([ - { name: 'Files', path: '', id: undefined }, - ]) + const [breadcrumbItems, setBreadcrumbItems] = useState([ROOT_BREADCRUMB]) const [selectedItems, setSelectedItems] = useState([]) const [viewMode, setViewMode] = useState('grid') const [filters, setFilters] = useState({ @@ -75,7 +112,12 @@ const FileManager = () => { sortOrder: 'asc', }) - // Modal states + // Tenant + folder navigation (single state => no fetch with a stale folder id) + const [nav, setNav] = useState({ tenantId: authTenantId, folderId: undefined }) + const currentFolderId = nav.folderId + const currentTenantId = nav.tenantId + + // Modal state const [uploadModalOpen, setUploadModalOpen] = useState(false) const [createFolderModalOpen, setCreateFolderModalOpen] = useState(false) const [renameModalOpen, setRenameModalOpen] = useState(false) @@ -83,671 +125,672 @@ const FileManager = () => { const [itemToRename, setItemToRename] = useState() const [itemsToDelete, setItemsToDelete] = useState([]) - // Tenant state + // Tenant list (host context only) const [tenants, setTenants] = useState([]) const [tenantsLoading, setTenantsLoading] = useState(false) - const [selectedTenant, setSelectedTenant] = useState<{ id: string; name: string } | undefined>( - authTenantId ? { id: authTenantId, name: authTenantName || '' } : undefined, - ) - // Tracks mid-flight tenant change so the fetch effect doesn't fire with a stale folderId - const pendingTenantChange = useRef(false) - // Loading states + // Busy flags const [uploading, setUploading] = useState(false) const [creating, setCreating] = useState(false) const [renaming, setRenaming] = useState(false) const [deleting, setDeleting] = useState(false) - // Fetch tenants - const fetchTenants = useCallback(async () => { - try { - setTenantsLoading(true) - const response = await getTenants(0, 1000) - setTenants(response.data.items || []) - } catch (error) { - console.error('Failed to fetch tenants:', error) - } finally { - setTenantsLoading(false) - } - }, []) + // Guards against out-of-order folder responses overwriting the current folder + const requestIdRef = useRef(0) + const notify = useCallback( + (type: 'success' | 'danger' | 'warning' | 'info', message: string, title?: string) => { + toast.push( + + {message} + , + TOAST_PLACEMENT, + ) + }, + [], + ) + + // Fetch tenants (host only) useEffect(() => { - if (isHostContext) { - fetchTenants() - } - }, [fetchTenants, isHostContext]) + if (!isHostContext) return - // If user is in a tenant context, lock selection to that tenant. + let cancelled = false + const fetchTenants = async () => { + try { + setTenantsLoading(true) + const response = await getTenants(0, 1000) + if (!cancelled) setTenants(response.data.items || []) + } catch (error) { + console.error('Failed to fetch tenants:', error) + } finally { + if (!cancelled) setTenantsLoading(false) + } + } + + fetchTenants() + return () => { + cancelled = true + } + }, [isHostContext]) + + // In a tenant context the selection is locked to the logged-in tenant. useEffect(() => { if (!authTenantId) return + setNav((prev) => (prev.tenantId === authTenantId ? prev : { tenantId: authTenantId })) + }, [authTenantId]) - setSelectedTenant((prev) => { - if (prev?.id === authTenantId) return prev - return { id: authTenantId, name: authTenantName || prev?.name || '' } - }) - }, [authTenantId, authTenantName]) + const selectedTenantName = useMemo(() => { + if (!currentTenantId) return '' + if (currentTenantId === authTenantId) return authTenantName || '' + return tenants.find((tenant) => tenant.id === currentTenantId)?.name || '' + }, [authTenantId, authTenantName, currentTenantId, tenants]) - // Reset navigation when tenant changes - useEffect(() => { - pendingTenantChange.current = true - setCurrentFolderId(undefined) - setSelectedItems([]) - setBreadcrumbItems([{ name: 'Files', path: '', id: undefined }]) - }, [selectedTenant]) + // Load folder content + breadcrumb for a tenant/folder pair + const loadFolder = useCallback( + async ({ tenantId, folderId }: NavigationState) => { + const requestId = ++requestIdRef.current + setLoading(true) - // Fetch items from API - const fetchItems = useCallback( - async (folderId?: string) => { try { - setLoading(true) - const response = await fileManagementService.getItems(folderId, selectedTenant?.id) - // Backend returns GetFilesDto which has Items property - const items = response.data.items || [] - setItems(items) + const [itemsResponse, pathResponse] = await Promise.all([ + fileManagementService.getItems(folderId, tenantId), + folderId ? fileManagementService.getFolderPath(folderId, tenantId) : undefined, + ]) + + if (requestId !== requestIdRef.current) return + + setItems(itemsResponse.data.items || []) + setBreadcrumbItems( + pathResponse + ? [ + ROOT_BREADCRUMB, + ...pathResponse.data.path.map((item) => ({ + name: item.name, + path: item.id, + id: item.id, + })), + ] + : [ROOT_BREADCRUMB], + ) } catch (error) { - console.error('Failed to fetch items:', error) - toast.push(Failed to load files and folders, { - placement: 'bottom-end', - }) + if (requestId !== requestIdRef.current) return + console.error('Failed to load folder:', error) + setItems([]) + notify('danger', 'Failed to load files and folders') } finally { - setLoading(false) + if (requestId === requestIdRef.current) setLoading(false) } }, - [selectedTenant], + [notify], ) - // Fetch breadcrumb path - const fetchBreadcrumb = useCallback( - async (folderId?: string) => { - try { - if (!folderId) { - setBreadcrumbItems([{ name: 'Files', path: '', id: undefined }]) - return - } - - const response = await fileManagementService.getFolderPath(folderId, selectedTenant?.id) - const pathItems: BreadcrumbItem[] = [ - { name: 'Files', path: '', id: undefined }, - ...response.data.path.map((item) => ({ - name: item.name, - path: item.id, - id: item.id, - })), - ] - setBreadcrumbItems(pathItems) - } catch (error) { - console.error('Failed to fetch breadcrumb:', error) - } - }, - [selectedTenant], - ) - - // Initial load useEffect(() => { - if (pendingTenantChange.current) { - pendingTenantChange.current = false - if (currentFolderId !== undefined) { - // The reset effect already called setCurrentFolderId(undefined); - // wait for that state update to re-trigger this effect at root. - return - } - } - fetchItems(currentFolderId) - fetchBreadcrumb(currentFolderId) - }, [currentFolderId, fetchItems, fetchBreadcrumb]) + loadFolder(nav) + }, [loadFolder, nav]) - // Filter and sort items - useEffect(() => { - let filtered = [...items] + const refresh = useCallback(() => loadFolder(nav), [loadFolder, nav]) - // Apply search filter - if (filters.searchTerm) { - filtered = filtered.filter((item) => - item.name.toLowerCase().includes(filters.searchTerm!.toLowerCase()), - ) - } + const navigateToFolder = useCallback((folderId?: string) => { + setSelectedItems([]) + setNav((prev) => (prev.folderId === folderId ? prev : { ...prev, folderId })) + }, []) - // Apply sorting - filtered.sort((a, b) => { - let comparison = 0 + // Filter + sort (folders first) in a single pass + const filteredItems = useMemo(() => { + const searchTerm = filters.searchTerm?.trim().toLowerCase() + const result = searchTerm + ? items.filter((item) => item.name.toLowerCase().includes(searchTerm)) + : [...items] + + const direction = filters.sortOrder === 'desc' ? -1 : 1 + + return result.sort((a, b) => { + if (a.type !== b.type) return a.type === 'folder' ? -1 : 1 switch (filters.sortBy) { - case 'name': - comparison = a.name.localeCompare(b.name) - break case 'size': - comparison = (a.size || 0) - (b.size || 0) - break + return ((a.size || 0) - (b.size || 0)) * direction case 'type': - comparison = a.type.localeCompare(b.type) - break + return a.type.localeCompare(b.type) * direction case 'modified': - comparison = new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime() - break + return ( + (new Date(a.modifiedAt).getTime() - new Date(b.modifiedAt).getTime()) * direction + ) + default: + return a.name.localeCompare(b.name) * direction } - - return filters.sortOrder === 'desc' ? -comparison : comparison }) - - // Folders first - filtered.sort((a, b) => { - if (a.type === 'folder' && b.type === 'file') return -1 - if (a.type === 'file' && b.type === 'folder') return 1 - return 0 - }) - - setFilteredItems(filtered) }, [items, filters]) - // Navigation handlers - const handleBreadcrumbNavigate = (breadcrumb: BreadcrumbItem) => { - setCurrentFolderId(breadcrumb.id) - setSelectedItems([]) - } + const selectableCount = useMemo( + () => filteredItems.filter((item) => !item.isReadOnly).length, + [filteredItems], + ) + const allSelected = selectableCount > 0 && selectedItems.length === selectableCount - const handleItemSelect = (item: FileItemType) => { - // Protected öğeler seçilemez - if (item.isReadOnly) { - return - } + // Selection always comes from the current folder, so resolve against `items` + const selectedItemObjects = useMemo( + () => items.filter((item) => selectedItems.includes(item.id)), + [items, selectedItems], + ) + const singleSelectedItem = selectedItemObjects.length === 1 ? selectedItemObjects[0] : undefined - setSelectedItems((prev) => { - if (prev.includes(item.id)) { - return prev.filter((id) => id !== item.id) - } else { - return [...prev, item.id] + const hasClipboardData = !!fileManagerClipboard + + // Selection handlers + const handleItemSelect = useCallback((item: FileItemType) => { + if (item.isReadOnly) return + + setSelectedItems((prev) => + prev.includes(item.id) ? prev.filter((id) => id !== item.id) : [...prev, item.id], + ) + }, []) + + const handleItemDoubleClick = useCallback( + (item: FileItemType, event?: MouseEvent) => { + event?.preventDefault() + event?.stopPropagation() + window.getSelection()?.removeAllRanges() + + if (item.type === 'folder') { + navigateToFolder(item.id) } - }) - } + }, + [navigateToFolder], + ) - const handleItemDoubleClick = (item: FileItemType, event?: MouseEvent) => { - // Prevent text selection and other default behaviors - if (event) { - event.preventDefault() - event.stopPropagation() - } + const selectAllItems = useCallback(() => { + setSelectedItems(filteredItems.filter((item) => !item.isReadOnly).map((item) => item.id)) + }, [filteredItems]) - // Clear any text selection that might have occurred - if (window.getSelection) { - const selection = window.getSelection() - if (selection) { - selection.removeAllRanges() - } - } - - if (item.type === 'folder') { - setCurrentFolderId(item.id) - setSelectedItems([]) - } - } + const deselectAllItems = useCallback(() => setSelectedItems([]), []) // File operations - const handleUploadFiles = async (files: File[]) => { - try { - setUploading(true) - for (const file of files) { - // NoteModal pattern'ini kullan - Files array ile FormData - const formData = new FormData() - formData.append('fileName', file.name) - formData.append('Files', file) // NoteModal pattern - Files array - if (currentFolderId) { - formData.append('parentId', currentFolderId) + const handleUploadFiles = useCallback( + async (files: File[]) => { + try { + setUploading(true) + for (const file of files) { + const formData = new FormData() + formData.append('fileName', file.name) + formData.append('Files', file) + if (currentFolderId) { + formData.append('parentId', currentFolderId) + } + + await fileManagementService.uploadFileDirectly(formData, currentTenantId) } - - await fileManagementService.uploadFileDirectly(formData, selectedTenant?.id) + await refresh() + notify('success', 'Files uploaded successfully') + } catch (error) { + console.error('Upload failed:', error) + notify('danger', 'Failed to upload files') + throw error + } finally { + setUploading(false) } - await fetchItems(currentFolderId) - toast.push(Files uploaded successfully, { - placement: 'bottom-end', - }) - } catch (error) { - console.error('Upload failed:', error) - toast.push(Failed to upload files, { - placement: 'bottom-end', - }) - throw error - } finally { - setUploading(false) - } - } + }, + [currentFolderId, currentTenantId, notify, refresh], + ) - const handleCreateFolder = async (name: string) => { - try { - setCreating(true) - await fileManagementService.createFolder( - { - name, - parentId: currentFolderId, - }, - selectedTenant?.id, - ) - await fetchItems(currentFolderId) - toast.push(Folder created successfully, { - placement: 'bottom-end', - }) - } catch (error) { - console.error('Create folder failed:', error) - toast.push(Failed to create folder, { - placement: 'bottom-end', - }) - throw error - } finally { - setCreating(false) - } - } + const handleCreateFolder = useCallback( + async (name: string) => { + try { + setCreating(true) + await fileManagementService.createFolder( + { name, parentId: currentFolderId }, + currentTenantId, + ) + await refresh() + notify('success', 'Folder created successfully') + } catch (error) { + console.error('Create folder failed:', error) + notify('danger', 'Failed to create folder') + throw error + } finally { + setCreating(false) + } + }, + [currentFolderId, currentTenantId, notify, refresh], + ) - const handleRenameItem = async (newName: string) => { - if (!itemToRename) return + const handleRenameItem = useCallback( + async (newName: string) => { + if (!itemToRename) return - try { - setRenaming(true) - await fileManagementService.renameItem( - { - id: itemToRename.id, - newName, - }, - selectedTenant?.id, - ) - await fetchItems(currentFolderId) - toast.push(Item renamed successfully, { - placement: 'bottom-end', - }) - } catch (error) { - console.error('Rename failed:', error) - toast.push(Failed to rename item, { - placement: 'bottom-end', - }) - throw error - } finally { - setRenaming(false) - } - } + try { + setRenaming(true) + await fileManagementService.renameItem( + { id: itemToRename.id, newName }, + currentTenantId, + ) + // Ids are path based, so the renamed item's id is no longer valid + setSelectedItems([]) + await refresh() + notify('success', 'Item renamed successfully') + } catch (error) { + console.error('Rename failed:', error) + notify('danger', 'Failed to rename item') + throw error + } finally { + setRenaming(false) + } + }, + [currentTenantId, itemToRename, notify, refresh], + ) + + const handleDeleteItems = useCallback(async () => { + if (itemsToDelete.length === 0) return - const handleDeleteItems = async () => { try { setDeleting(true) if (itemsToDelete.length === 1) { - // Single item delete - use existing API - await fileManagementService.deleteItem({ id: itemsToDelete[0].id }, selectedTenant?.id) + await fileManagementService.deleteItem({ id: itemsToDelete[0].id }, currentTenantId) } else { - // Multiple items - use bulk delete API - const itemIds = itemsToDelete.map((item) => item.id) - await fileManagementService.bulkDeleteItems(itemIds, selectedTenant?.id) + await fileManagementService.bulkDeleteItems( + itemsToDelete.map((item) => item.id), + currentTenantId, + ) } - await fetchItems(currentFolderId) setSelectedItems([]) - toast.push(Items deleted successfully, { - placement: 'bottom-end', - }) + await refresh() + notify('success', 'Items deleted successfully') } catch (error) { console.error('Delete failed:', error) - toast.push(Failed to delete items, { - placement: 'bottom-end', - }) + notify('danger', 'Failed to delete items') throw error } finally { setDeleting(false) } - } + }, [currentTenantId, itemsToDelete, notify, refresh]) - const handleDownload = (item: FileItemType) => { - const tenantSegment = selectedTenant?.id ? `tenants/${selectedTenant.id}` : 'host' - const filePath = item.id.replace(/\|/g, '/') - const url = `${VITE_CDN_URL}/${tenantSegment}/${filePath}` - const a = document.createElement('a') - a.href = url - a.download = item.name - a.target = '_blank' - a.rel = 'noopener noreferrer' - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - } + const handleDownload = useCallback( + (item: FileItemType) => { + const url = buildDownloadUrl(item, currentTenantId) + if (!url) { + notify('danger', 'File location is invalid') + return + } - // Action handlers - const openRenameModal = (item: FileItemType) => { + const anchor = document.createElement('a') + anchor.href = url + anchor.download = item.name + anchor.target = '_blank' + anchor.rel = 'noopener noreferrer' + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + }, + [currentTenantId, notify], + ) + + const openRenameModal = useCallback((item: FileItemType) => { setItemToRename(item) setRenameModalOpen(true) - } + }, []) - const openDeleteModal = (items: FileItemType[]) => { - setItemsToDelete(items) + const openDeleteModal = useCallback((targets: FileItemType[]) => { + setItemsToDelete(targets) setDeleteModalOpen(true) - } + }, []) - const handleSingleItemDelete = async (item: FileItemType) => { - // Check if it's a protected item - if (item.isReadOnly) { - toast.push( - - Protected system folders cannot be deleted. - , - { placement: 'bottom-end' }, + /** Returns the ids of folders that still contain files (checked in parallel). */ + const findNonEmptyFolderIds = useCallback( + async (candidates: FileItemType[]): Promise> => { + const folders = candidates.filter((item) => item.type === 'folder') + if (folders.length === 0) return new Set() + + const results = await Promise.all( + folders.map(async (folder) => { + try { + const response = await fileManagementService.getItems(folder.id, currentTenantId) + const hasFiles = (response.data.items || []).some((child) => child.type === 'file') + return hasFiles ? folder.id : undefined + } catch (error) { + console.error('Error checking folder contents:', error) + // Fail closed: treat unknown content as non-deletable + return folder.id + } + }), ) - return - } - // Check if it's a folder containing files - if (item.type === 'folder') { - const hasFiles = await checkFolderHasFiles(item.id) - if (hasFiles) { - toast.push( - - Folder {item.name} contains files and cannot be deleted for security reasons. - , - { placement: 'bottom-end' }, + return new Set(results.filter((id): id is string => !!id)) + }, + [currentTenantId], + ) + + const handleSingleItemDelete = useCallback( + async (item: FileItemType) => { + if (item.isReadOnly) { + notify('warning', 'Protected system folders cannot be deleted.', 'Warning') + return + } + + const nonEmptyFolderIds = await findNonEmptyFolderIds([item]) + if (nonEmptyFolderIds.has(item.id)) { + notify( + 'warning', + `Folder ${item.name} contains files and cannot be deleted for security reasons.`, + 'Security Warning', ) return } - } - // If all checks pass, open delete modal - openDeleteModal([item]) - } + openDeleteModal([item]) + }, + [findNonEmptyFolderIds, notify, openDeleteModal], + ) - const goUpOneLevel = () => { - if (breadcrumbItems.length > 1) { - const parentBreadcrumb = breadcrumbItems[breadcrumbItems.length - 2] - handleBreadcrumbNavigate(parentBreadcrumb) - } - } + const deleteSelectedItems = useCallback(async () => { + if (selectedItemObjects.length === 0) return - const hasClipboardData = !!fileManagerClipboard + const protectedItems = selectedItemObjects.filter((item) => item.isReadOnly) + const deletableItems = selectedItemObjects.filter((item) => !item.isReadOnly) - // Keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey || e.metaKey) { - switch (e.key) { - case 'a': - e.preventDefault() - selectAllItems() - break - case 'c': - e.preventDefault() - if (selectedItems.length > 0) { - copySelectedItems() - } - break - case 'x': - e.preventDefault() - if (selectedItems.length > 0) { - cutSelectedItems() - } - break - case 'v': - e.preventDefault() - pasteItems() - break - case 'Delete': - case 'Backspace': - e.preventDefault() - if (selectedItems.length > 0) { - deleteSelectedItems() - } - break - } - } else if (e.key === 'Delete') { - e.preventDefault() - if (selectedItems.length > 0) { - deleteSelectedItems() - } - } - } + const nonEmptyFolderIds = await findNonEmptyFolderIds(deletableItems) + const blockedFolders = deletableItems.filter((item) => nonEmptyFolderIds.has(item.id)) + const finalDeletableItems = deletableItems.filter((item) => !nonEmptyFolderIds.has(item.id)) - document.addEventListener('keydown', handleKeyDown) - return () => { - document.removeEventListener('keydown', handleKeyDown) - } - // Keyboard shortcuts should bind once per selection/list state snapshot. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedItems, filteredItems]) - - // Bulk operations - const selectAllItems = () => { - // Sadece protected olmayan öğeleri seç - const selectableItems = filteredItems.filter((item) => !item.isReadOnly) - setSelectedItems(selectableItems.map((item) => item.id)) - } - - const deselectAllItems = () => { - setSelectedItems([]) - } - - // Check if a folder contains files (for security purposes) - const checkFolderHasFiles = async (folderId: string): Promise => { - try { - const response = await fileManagementService.getItems(folderId, selectedTenant?.id) - const items = response.data.items || [] - // Check if folder contains any files (not just other folders) - return items.some((item) => item.type === 'file') - } catch (error) { - console.error('Error checking folder contents:', error) - return false - } - } - - const deleteSelectedItems = async () => { - const itemsToDelete = filteredItems.filter((item) => selectedItems.includes(item.id)) - const deletableItems = itemsToDelete.filter((item) => !item.isReadOnly) - const protectedItems = itemsToDelete.filter((item) => item.isReadOnly) - - // Check for folders containing files - const foldersWithFiles: string[] = [] - for (const item of deletableItems) { - if (item.type === 'folder') { - const hasFiles = await checkFolderHasFiles(item.id) - if (hasFiles) { - foldersWithFiles.push(item.name) - } - } - } - - // Filter out folders that contain files - const finalDeletableItems = deletableItems.filter((item) => { - if (item.type === 'folder') { - return !foldersWithFiles.includes(item.name) - } - return true - }) - - // Show warnings if (protectedItems.length > 0) { - toast.push( - - {protectedItems.length} protected system folder(s) cannot be deleted:{' '} - {protectedItems.map((i) => i.name).join(', ')} - , - { placement: 'bottom-end' }, + notify( + 'warning', + `${protectedItems.length} protected system folder(s) cannot be deleted: ${protectedItems + .map((item) => item.name) + .join(', ')}`, + 'Warning', ) } - if (foldersWithFiles.length > 0) { - toast.push( - - {foldersWithFiles.length} folder(s) containing files cannot be deleted for security - reasons: {foldersWithFiles.join(', ')} - , - { placement: 'bottom-end' }, + if (blockedFolders.length > 0) { + notify( + 'warning', + `${blockedFolders.length} folder(s) containing files cannot be deleted for security reasons: ${blockedFolders + .map((item) => item.name) + .join(', ')}`, + 'Security Warning', ) } if (finalDeletableItems.length > 0) { + const deletableIds = new Set(finalDeletableItems.map((item) => item.id)) + setSelectedItems((prev) => prev.filter((id) => deletableIds.has(id))) openDeleteModal(finalDeletableItems) - // Remove protected items and folders with files from selection - const deletableIds = finalDeletableItems.map((item) => item.id) - setSelectedItems((prev) => prev.filter((id) => deletableIds.includes(id))) - } else if (itemsToDelete.length > 0) { - // If no items can be deleted, show info message - toast.push( - - No items can be deleted. Selected items are either protected or folders containing files. - , - { placement: 'bottom-end' }, + } else { + notify( + 'info', + 'No items can be deleted. Selected items are either protected or folders containing files.', + 'Info', ) } - } + }, [findNonEmptyFolderIds, notify, openDeleteModal, selectedItemObjects]) - const copySelectedItems = () => { - const itemsToCopy = filteredItems.filter((item) => selectedItems.includes(item.id)) - const copyableItems = itemsToCopy.filter((item) => !item.isReadOnly) - const protectedItems = itemsToCopy.filter((item) => item.isReadOnly) + const putOnClipboard = useCallback( + (operation: 'copy' | 'cut', targets: FileItemType[]) => { + const protectedItems = targets.filter((item) => item.isReadOnly) + const allowedItems = targets.filter((item) => !item.isReadOnly) - if (protectedItems.length > 0) { - toast.push( - - {protectedItems.length} protected system folder(s) cannot be copied:{' '} - {protectedItems.map((i) => i.name).join(', ')} - , - { placement: 'bottom-end' }, - ) - } + if (protectedItems.length > 0) { + notify( + 'warning', + `${protectedItems.length} protected system folder(s) cannot be ${ + operation === 'copy' ? 'copied' : 'moved' + }: ${protectedItems.map((item) => item.name).join(', ')}`, + 'Warning', + ) + } + + if (allowedItems.length === 0) return - if (copyableItems.length > 0) { setFileManagerClipboard({ - operation: 'copy', - items: copyableItems, + operation, + items: allowedItems, sourceFolder: currentFolderId, + sourceTenantId: currentTenantId, }) - toast.push( - - {copyableItems.length} item(s) copied to clipboard - , - { placement: 'bottom-end' }, + notify( + 'success', + `${allowedItems.length} item(s) ${operation === 'copy' ? 'copied' : 'cut'} to clipboard`, + operation === 'copy' ? 'Copied' : 'Cut', ) - } - } + }, + [currentFolderId, currentTenantId, notify, setFileManagerClipboard], + ) - const cutSelectedItems = () => { - const itemsToCut = filteredItems.filter((item) => selectedItems.includes(item.id)) - const cuttableItems = itemsToCut.filter((item) => !item.isReadOnly) - const protectedItems = itemsToCut.filter((item) => item.isReadOnly) + const copySelectedItems = useCallback( + () => putOnClipboard('copy', selectedItemObjects), + [putOnClipboard, selectedItemObjects], + ) - if (protectedItems.length > 0) { - toast.push( - - {protectedItems.length} protected system folder(s) cannot be moved:{' '} - {protectedItems.map((i) => i.name).join(', ')} - , - { placement: 'bottom-end' }, - ) - } + const cutSelectedItems = useCallback( + () => putOnClipboard('cut', selectedItemObjects), + [putOnClipboard, selectedItemObjects], + ) - if (cuttableItems.length > 0) { - setFileManagerClipboard({ - operation: 'cut', - items: cuttableItems, - sourceFolder: currentFolderId, - }) - toast.push( - - {cuttableItems.length} item(s) cut to clipboard - , - { placement: 'bottom-end' }, - ) - } - } - - const pasteItems = async () => { - if (!fileManagerClipboard) { - toast.push( - - No items in clipboard - , - { placement: 'bottom-end' }, - ) + const pasteItems = useCallback(async () => { + const clipboard = fileManagerClipboard + if (!clipboard || clipboard.items.length === 0) { + notify('info', 'No items in clipboard', 'Clipboard Empty') return } - try { - const clipboard = fileManagerClipboard - const itemIds = clipboard.items.map((item) => item.id) - - if (clipboard.operation === 'copy') { - setLoading(true) - try { - await fileManagementService.copyItems(itemIds, currentFolderId, selectedTenant?.id) - await fetchItems(currentFolderId) - toast.push( - - {itemIds.length} item(s) copied successfully - , - { placement: 'bottom-end' }, - ) - } catch (error) { - console.error('Copy failed:', error) - toast.push( - - Failed to copy items - , - { placement: 'bottom-end' }, - ) - } finally { - setLoading(false) - } - } else if (clipboard.operation === 'cut') { - // Aynı klasörde move yapmaya çalışırsa engelleyelim - if (clipboard.sourceFolder === currentFolderId) { - toast.push( - - Cannot move items to the same folder - , - { placement: 'bottom-end' }, - ) - return - } - - setLoading(true) - try { - await fileManagementService.moveItems(itemIds, currentFolderId, selectedTenant?.id) - await fetchItems(currentFolderId) - clearFileManagerClipboard() - toast.push( - - {itemIds.length} item(s) moved successfully - , - { placement: 'bottom-end' }, - ) - } catch (error) { - console.error('Move failed:', error) - toast.push( - - Failed to move items - , - { placement: 'bottom-end' }, - ) - } finally { - setLoading(false) - } - } - } catch { - toast.push( - - Invalid clipboard data - , - { placement: 'bottom-end' }, - ) + // Tenant isolation: never move/copy content across tenant boundaries. + if (clipboard.sourceTenantId !== currentTenantId) { + notify('warning', 'Items cannot be pasted into a different tenant', 'Warning') + return } + + if (clipboard.operation === 'cut' && clipboard.sourceFolder === currentFolderId) { + notify('warning', 'Cannot move items to the same folder', 'Warning') + return + } + + const itemIds = clipboard.items.map((item) => item.id) + const isCopy = clipboard.operation === 'copy' + + setLoading(true) + try { + if (isCopy) { + await fileManagementService.copyItems(itemIds, currentFolderId, currentTenantId) + } else { + await fileManagementService.moveItems(itemIds, currentFolderId, currentTenantId) + clearFileManagerClipboard() + } + + await refresh() + notify( + 'success', + `${itemIds.length} item(s) ${isCopy ? 'copied' : 'moved'} successfully`, + translate('::App.Platform.Success'), + ) + } catch (error) { + console.error(isCopy ? 'Copy failed:' : 'Move failed:', error) + notify( + 'danger', + isCopy ? 'Failed to copy items' : 'Failed to move items', + translate('::App.Platform.Error'), + ) + } finally { + setLoading(false) + } + }, [ + clearFileManagerClipboard, + currentFolderId, + currentTenantId, + fileManagerClipboard, + notify, + refresh, + translate, + ]) + + const goUpOneLevel = useCallback(() => { + if (breadcrumbItems.length > 1) { + navigateToFolder(breadcrumbItems[breadcrumbItems.length - 2].id) + } + }, [breadcrumbItems, navigateToFolder]) + + const handleRenameSelected = useCallback(() => { + if (!singleSelectedItem) return + if (singleSelectedItem.isReadOnly) { + notify('warning', 'Protected system folders cannot be renamed', 'Warning') + return + } + openRenameModal(singleSelectedItem) + }, [notify, openRenameModal, singleSelectedItem]) + + const handleDownloadSelected = useCallback(() => { + if (singleSelectedItem?.type === 'file') { + handleDownload(singleSelectedItem) + } + }, [handleDownload, singleSelectedItem]) + + // Keyboard shortcuts — read the latest handlers through a ref to avoid stale closures + const shortcutsRef = useRef({ + selectAllItems, + copySelectedItems, + cutSelectedItems, + pasteItems, + deleteSelectedItems, + hasSelection: false, + }) + shortcutsRef.current = { + selectAllItems, + copySelectedItems, + cutSelectedItems, + pasteItems, + deleteSelectedItems, + hasSelection: selectedItems.length > 0, } + const anyModalOpen = + uploadModalOpen || createFolderModalOpen || renameModalOpen || deleteModalOpen + + useEffect(() => { + if (anyModalOpen) return + + const handleKeyDown = (e: KeyboardEvent) => { + // Never steal shortcuts from inputs, textareas or contenteditable fields + if (isEditableTarget(e.target)) return + + const shortcuts = shortcutsRef.current + + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'a': + e.preventDefault() + shortcuts.selectAllItems() + break + case 'c': + if (!shortcuts.hasSelection) return + e.preventDefault() + shortcuts.copySelectedItems() + break + case 'x': + if (!shortcuts.hasSelection) return + e.preventDefault() + shortcuts.cutSelectedItems() + break + case 'v': + e.preventDefault() + shortcuts.pasteItems() + break + } + return + } + + if (e.key === 'Delete' && shortcuts.hasSelection) { + e.preventDefault() + shortcuts.deleteSelectedItems() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [anyModalOpen]) + + // FileItem callbacks (stable identities so list items don't re-render needlessly) + const handleCreateFolderInside = useCallback( + (parentItem: FileItemType) => { + navigateToFolder(parentItem.id) + setCreateFolderModalOpen(true) + }, + [navigateToFolder], + ) + + const handleMoveItem = useCallback( + (item: FileItemType) => { + setSelectedItems([item.id]) + putOnClipboard('cut', [item]) + }, + [putOnClipboard], + ) + + const handlePreview = useCallback( + (item: FileItemType) => { + if (item.mimeType?.startsWith('image/')) { + notify('info', 'Image preview feature will be added soon.') + return + } + handleDownload(item) + }, + [handleDownload, notify], + ) + + const sortOptions = useMemo( + () => [ + { value: 'name-asc', label: translate('::FileManager.SortByNameAsc') }, + { value: 'name-desc', label: translate('::FileManager.SortByNameDesc') }, + { value: 'size-asc', label: translate('::FileManager.SortBySizeAsc') }, + { value: 'size-desc', label: translate('::FileManager.SortBySizeDesc') }, + { value: 'modified-desc', label: translate('::FileManager.SortByModifiedDesc') }, + { value: 'modified-asc', label: translate('::FileManager.SortByModifiedAsc') }, + ], + [translate], + ) + + const selectedSortOption = useMemo(() => { + const value = `${filters.sortBy}-${filters.sortOrder}` + return sortOptions.find((option) => option.value === value) ?? sortOptions[0] + }, [filters.sortBy, filters.sortOrder, sortOptions]) + + const tenantOptions = useMemo( + () => [ + { + value: '', + label: 'Host', + icon: , + }, + ...tenants + .filter((tenant) => !!tenant.id) + .map((tenant) => ({ + value: tenant.id as string, + label: tenant.name ?? '', + icon: , + })), + ], + [tenants], + ) + + const selectedTenantOption = useMemo( + () => + tenantOptions.find((option) => option.value === (currentTenantId ?? '')) ?? tenantOptions[0], + [currentTenantId, tenantOptions], + ) + return ( + /> - {/* Enhanced Unified Toolbar */} + {/* Toolbar */}
- {/* Main Toolbar Row */}
{/* Left Section - Primary Actions */}
- {/* File Operations */} - {/* Navigation */} + onClick={handleRenameSelected} + /> + onClick={handleDownloadSelected} + />
) : ( <> - {/* List View Header */} - {viewMode === 'list' && ( + {viewMode === 'list' && filteredItems.length > 0 && (
-
- {translate('::App.Listform.ListformField.Name')} -
-
- {translate('::App.Listform.ListformField.Type')} -
-
- {translate('::App.Listform.ListformField.Size')} -
-
- {translate('::App.Listform.ListformField.Modified')} -
-
{/* Actions column */} +
{translate('::App.Listform.ListformField.Name')}
+
{translate('::App.Listform.ListformField.Type')}
+
{translate('::App.Listform.ListformField.Size')}
+
{translate('::App.Listform.ListformField.Modified')}
+
)} @@ -1098,52 +1040,12 @@ const FileManager = () => { onSelect={handleItemSelect} onDoubleClick={handleItemDoubleClick} onToggleSelect={handleItemSelect} - onCreateFolder={(parentItem) => { - // Klasör içinde yeni klasör oluşturmak için parent klasörü set et - setCurrentFolderId(parentItem.id) - setCreateFolderModalOpen(true) - }} + onCreateFolder={handleCreateFolderInside} onRename={openRenameModal} - onMove={(item) => { - // Move işlemi için öğeyi cut olarak clipboard'a koy - if (selectedItems.includes(item.id)) { - cutSelectedItems() - } else { - setSelectedItems([item.id]) - setFileManagerClipboard({ - operation: 'cut', - items: [item], - sourceFolder: currentFolderId, - }) - toast.push( - - Item ready to move. Navigate to target folder and paste. - , - { placement: 'bottom-end' }, - ) - } - }} + onMove={handleMoveItem} onDelete={handleSingleItemDelete} - onDownload={item.type === 'file' ? handleDownload : undefined} - onPreview={ - item.type === 'file' - ? (item) => { - // Preview işlevi - resimler için modal açabiliriz - if (item.mimeType?.startsWith('image/')) { - // Resim preview modal'ı açılabilir - toast.push( - - Image preview feature will be added soon. - , - { placement: 'bottom-end' }, - ) - } else { - // Diğer dosya tipleri için download - handleDownload(item) - } - } - : undefined - } + onDownload={handleDownload} + onPreview={handlePreview} /> )) )} diff --git a/ui/src/views/admin/files/components/FileItem.tsx b/ui/src/views/admin/files/components/FileItem.tsx index b1e0d263..1e706912 100644 --- a/ui/src/views/admin/files/components/FileItem.tsx +++ b/ui/src/views/admin/files/components/FileItem.tsx @@ -1,4 +1,12 @@ -import { forwardRef, useState, useEffect, type ChangeEvent, type MouseEvent } from 'react' +import { + forwardRef, + memo, + useState, + useEffect, + type ChangeEvent, + type MouseEvent, + type ReactNode, +} from 'react' import classNames from 'classnames' import { FaFolder, @@ -120,6 +128,28 @@ const getFileTypeLabel = (item: FileItemType, translate: (key: string) => string return extension?.toUpperCase() || translate('::FileManager.File') } +// Defined at module level: nesting it inside FileItem would remount the +// (and restart the download) on every parent render. +const ImagePreview = ({ src, alt, fallback }: { src: string; alt: string; fallback: ReactNode }) => { + const [imageError, setImageError] = useState(false) + + return ( +
+ {!imageError ? ( + {alt} setImageError(true)} + /> + ) : ( +
{fallback}
+ )} +
+ ) +} + const FileItem = forwardRef((props, ref) => { const { item, @@ -332,28 +362,6 @@ const FileItem = forwardRef((props, ref) => {
) - // Resim preview komponenti - const ImagePreview = ({ src, alt }: { src: string; alt: string }) => { - const [imageError, setImageError] = useState(false) - - return ( -
- {!imageError ? ( - {alt} setImageError(true)} - /> - ) : ( -
- {getFileIcon(item, false)} -
- )} -
- ) - } - if (viewMode === 'list') { return ( <> @@ -389,7 +397,11 @@ const FileItem = forwardRef((props, ref) => { {/* File Icon or Preview */}
{item.type === 'file' && item.mimeType?.startsWith('image/') ? ( - + ) : (
{getFileIcon(item, false)} @@ -484,7 +496,11 @@ const FileItem = forwardRef((props, ref) => {
{item.type === 'file' && item.mimeType?.startsWith('image/') ? (
- +
) : (
{getFileIcon(item, true)}
@@ -535,4 +551,4 @@ const FileItem = forwardRef((props, ref) => { FileItem.displayName = 'FileItem' -export default FileItem +export default memo(FileItem)