Sfoglia il codice sorgente

feat(library): let users delete empty folders (#1781)

Library folders have no ownership tracking, so folder deletion was
gated entirely behind library:delete_all - a user with
library:delete_own could create folders and delete their own files,
but the emptied folder sat there until an admin removed it.

Users with library:delete_own can now delete folders that are truly
empty: no subfolders and no files, including trashed ones - folder
deletion cascades, so removing a folder that holds another user's
trashed file would silently break trash restore. External folders
(operator-configured mounts) and folders linked to a project or
archive still require library:delete_all even when empty, since
deleting them affects more than the folder itself. The bulk-delete
endpoint applies the same rule instead of skipping all folders for
non-admin users.

The folder tree's Delete entry enables accordingly and shows a
"You can only delete empty folders" hint on non-empty folders. The
backend stays authoritative - a folder that only contains trashed
files is invisible in the tree but still refuses deletion.
maziggy 1 mese fa
parent
commit
1fb6978ee1

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 ## [1.2.6b1] - Unreleased
 
 
 ### Added
 ### Added
+- **Users can now delete empty folders in the File Manager (#1781, reporter @cadtoolbox)** — Library folders have no ownership tracking, so folder deletion was gated entirely behind `library:delete_all` — a regular user with `library:delete_own` could create folders and delete their own files, but the emptied folder sat there until an admin removed it. Users with `library:delete_own` can now delete folders that are truly empty: no subfolders, no files — including trashed ones, since deleting a folder would silently drop another user's trash-restorable files. External folders (operator-configured mounts) and folders linked to a project or archive still require `library:delete_all`, even when empty. The folder tree's Delete entry enables accordingly, with a "You can only delete empty folders" tooltip on non-empty ones; the bulk-delete API applies the same rule. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **AI failure detection is now visible on the printer cards (#1546, reporter @Jeff-GebhartCA)** — Previously the live Obico classification (safe / warning / failure, smoothed score) was only visible under Settings → Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray **Idle** while no print is being watched, then green **Safe**, amber **Warning**, or red **Failure** while a print is actively monitored. The tooltip carries the current score, and clicking opens a modal (like the HMS error badge) with the live status, score, frames analyzed, and the detection service's last error — plus a shortcut to the full settings. Toggling detection on or off updates the cards immediately. Printers excluded from monitoring and setups without failure detection show nothing. Served by a new lightweight `/obico/printer-status` endpoint readable with printer permissions alone (the existing settings-gated endpoint is unchanged and keeps configuration private). Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **AI failure detection is now visible on the printer cards (#1546, reporter @Jeff-GebhartCA)** — Previously the live Obico classification (safe / warning / failure, smoothed score) was only visible under Settings → Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray **Idle** while no print is being watched, then green **Safe**, amber **Warning**, or red **Failure** while a print is actively monitored. The tooltip carries the current score, and clicking opens a modal (like the HMS error badge) with the live status, score, frames analyzed, and the detection service's last error — plus a shortcut to the full settings. Toggling detection on or off updates the cards immediately. Printers excluded from monitoring and setups without failure detection show nothing. Served by a new lightweight `/obico/printer-status` endpoint readable with printer permissions alone (the existing settings-gated endpoint is unchanged and keeps configuration private). Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Bark is now a notification provider (#1495)** — [Bark](https://github.com/Finb/Bark) is the open-source, account-free iOS push app (self-hostable via bark-server), popular especially with Chinese-speaking users. Configure it with just the device key from the app; the server URL defaults to the official `api.day.app` relay and accepts a self-hosted instance. Optional settings: notification **Group**, **Sound**, and iOS **Interruption Level** — Time Sensitive breaks through scheduled summaries, Critical bypasses Silent mode and Focus (useful for print-failure alerts), Passive delivers silently. Send failures wrapped in an HTTP 200 body by bark-server are detected and reported properly. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Bark is now a notification provider (#1495)** — [Bark](https://github.com/Finb/Bark) is the open-source, account-free iOS push app (self-hostable via bark-server), popular especially with Chinese-speaking users. Configure it with just the device key from the app; the server URL defaults to the official `api.day.app` relay and accepts a self-hosted instance. Optional settings: notification **Group**, **Sound**, and iOS **Interruption Level** — Time Sensitive breaks through scheduled summaries, Critical bypasses Silent mode and Focus (useful for print-failure alerts), Passive delivers silently. Send failures wrapped in an HTTP 200 body by bark-server are detected and reported properly. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Home Assistant notifications can carry custom data fields (#1441)** — When a notification provider targets an HA notify service (e.g. `notify.mobile_app_myphone`), a new optional **Data (JSON)** field is forwarded as the service call's nested `data` object — the same place HA automations put mobile push options like `priority`, `ttl`, `channel`, and `group`. `ttl: 0` + `priority: high` make Android pushes arrive immediately instead of batched, and `channel` gives printer alerts their own notification channel/sound. The field is JSON (not key=value lines) so numbers stay numbers (`ttl: 0`) and nested options work. Validated on both ends: the UI rejects malformed JSON before saving, and the sender fails loudly with a clear message rather than posting a half-built payload. Only included when configured — the default persistent-notification path is unchanged, as its schema rejects unknown keys. Translated in all locales; wiki updated. Covered by backend and frontend tests.
 - **Home Assistant notifications can carry custom data fields (#1441)** — When a notification provider targets an HA notify service (e.g. `notify.mobile_app_myphone`), a new optional **Data (JSON)** field is forwarded as the service call's nested `data` object — the same place HA automations put mobile push options like `priority`, `ttl`, `channel`, and `group`. `ttl: 0` + `priority: high` make Android pushes arrive immediately instead of batched, and `channel` gives printer alerts their own notification channel/sound. The field is JSON (not key=value lines) so numbers stay numbers (`ttl: 0`) and nested options work. Validated on both ends: the UI rejects malformed JSON before saving, and the sender fails loudly with a clear message rather than posting a half-built payload. Only included when configured — the default persistent-notification path is unchanged, as its schema rejects unknown keys. Translated in all locales; wiki updated. Covered by backend and frontend tests.

+ 46 - 9
backend/app/api/routes/library.py

@@ -1245,23 +1245,61 @@ async def update_folder(
     )
     )
 
 
 
 
+async def _restricted_folder_delete_blocker(db: AsyncSession, folder: LibraryFolder) -> str | None:
+    """Why a library:delete_own user may NOT delete this folder, or None if they may.
+
+    Folders have no ownership tracking, so users without library:delete_all may
+    only delete folders that are truly empty — an empty folder contains nobody's
+    data (#1781). "Empty" must include trashed files: LibraryFile.folder_id
+    cascades on folder delete, so a folder holding another user's trashed file
+    would silently break trash restore.
+    """
+    if folder.is_external:
+        return "External folders can only be deleted by users with library:delete_all"
+    if folder.project_id is not None or folder.archive_id is not None:
+        return "Folders linked to a project or archive can only be deleted by users with library:delete_all"
+
+    child_result = await db.execute(select(func.count(LibraryFolder.id)).where(LibraryFolder.parent_id == folder.id))
+    if (child_result.scalar() or 0) > 0:
+        return "Only empty folders can be deleted without library:delete_all"
+
+    # Includes trashed files (no deleted_at filter) — see docstring.
+    file_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id))
+    if (file_result.scalar() or 0) > 0:
+        return "Only empty folders can be deleted without library:delete_all (the folder may contain trashed files)"
+
+    return None
+
+
 @router.delete("/folders/{folder_id}")
 @router.delete("/folders/{folder_id}")
 async def delete_folder(
 async def delete_folder(
     folder_id: int,
     folder_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_DELETE_ALL)),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_DELETE_ALL,
+            Permission.LIBRARY_DELETE_OWN,
+        )
+    ),
 ):
 ):
     """Delete a folder and all its contents (cascade).
     """Delete a folder and all its contents (cascade).
 
 
-    Note: Folders require library:delete_all permission since they don't have
-    ownership tracking.
+    Folders have no ownership tracking, so cascade deletion requires
+    library:delete_all. Users with only library:delete_own may delete empty,
+    non-external, non-linked folders (#1781).
     """
     """
+    _, can_modify_all = auth_result
     result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
     result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
     folder = result.scalar_one_or_none()
     folder = result.scalar_one_or_none()
 
 
     if not folder:
     if not folder:
         raise HTTPException(status_code=404, detail="Folder not found")
         raise HTTPException(status_code=404, detail="Folder not found")
 
 
+    if not can_modify_all:
+        blocker = await _restricted_folder_delete_blocker(db, folder)
+        if blocker:
+            raise HTTPException(status_code=403, detail=blocker)
+
     # External folders: only remove DB records, never delete files from external path
     # External folders: only remove DB records, never delete files from external path
     is_ext = folder.is_external
     is_ext = folder.is_external
 
 
@@ -4889,16 +4927,15 @@ async def bulk_delete(
             file.deleted_at = now
             file.deleted_at = now
         deleted_files += 1
         deleted_files += 1
 
 
-    # Delete folders (cascade will handle contents)
-    # Note: Folders don't have ownership tracking currently, require *_all permission
+    # Delete folders (cascade will handle contents). Folders have no ownership
+    # tracking, so users without *_all permission may only delete empty,
+    # non-external, non-linked folders (#1781) — same rule as DELETE /folders/{id}.
     for folder_id in data.folder_ids:
     for folder_id in data.folder_ids:
-        if not can_modify_all:
-            # Users without *_all permission cannot delete folders
-            continue
-
         result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
         result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
         folder = result.scalar_one_or_none()
         folder = result.scalar_one_or_none()
         if folder:
         if folder:
+            if not can_modify_all and await _restricted_folder_delete_blocker(db, folder):
+                continue
             # Count files that will be deleted
             # Count files that will be deleted
             file_count_result = await db.execute(
             file_count_result = await db.execute(
                 select(func.count(LibraryFile.id)).where(
                 select(func.count(LibraryFile.id)).where(

+ 157 - 4
backend/tests/integration/test_ownership_permissions.py

@@ -827,13 +827,106 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
 
 
         assert response.status_code == 403
         assert response.status_code == 403
 
 
+    # ========================================================================
+    # Folder deletion (#1781): folders have no ownership tracking, so users
+    # with only library:delete_own may delete empty, non-external, non-linked
+    # folders. Everything else still requires library:delete_all.
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_delete_empty_folder(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory
+    ):
+        """A user with library:delete_own can delete an empty folder (#1781)."""
+        folder = await library_folder_factory(name="EmptyFolder")
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_viewer_cannot_delete_empty_folder(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory
+    ):
+        """No delete permission at all still means no folder deletion."""
+        folder = await library_folder_factory(name="EmptyFolder")
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_folder_with_files(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
+    ):
+        """Non-empty folders still require library:delete_all."""
+        folder = await library_folder_factory(name="FullFolder")
+        await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_folder_with_trashed_file(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
+    ):
+        """Trashed files count as content: cascade would hard-drop them and
+        silently break trash restore for their owner."""
+        from datetime import datetime, timezone
+
+        folder = await library_folder_factory(name="TrashedContentFolder")
+        await library_file_factory(
+            folder_id=folder.id,
+            created_by_id=auth_setup["operator2_user"]["id"],
+            deleted_at=datetime.now(timezone.utc),
+        )
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
-    async def test_folders_require_all_permission(self, async_client: AsyncClient, auth_setup, library_folder_factory):
-        """Folders require *_all permission (no ownership tracking on folders)."""
-        folder = await library_folder_factory(name="TestFolder")
+    async def test_operator_cannot_delete_folder_with_subfolder(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory
+    ):
+        """A folder containing subfolders (even empty ones) is not empty."""
+        parent = await library_folder_factory(name="ParentFolder")
+        await library_folder_factory(name="ChildFolder", parent_id=parent.id)
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{parent.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_external_folder(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory
+    ):
+        """Deleting an external folder unmounts an operator-configured mount
+        for everyone — stays behind library:delete_all even when empty."""
+        folder = await library_folder_factory(name="ExternalFolder", is_external=True, external_path="/mnt/models")
 
 
-        # Operator cannot delete folder (needs *_all)
         response = await async_client.delete(
         response = await async_client.delete(
             f"/api/v1/library/folders/{folder.id}",
             f"/api/v1/library/folders/{folder.id}",
             headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
             headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
@@ -841,6 +934,66 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
 
 
         assert response.status_code == 403
         assert response.status_code == 403
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_delete_linked_folder(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory, db_session
+    ):
+        """Project/archive links are created via update_all, so unlinking by
+        deletion stays admin-only even for empty folders."""
+        from backend.app.models.project import Project
+
+        project = Project(name="LinkTestProject")
+        db_session.add(project)
+        await db_session.commit()
+        await db_session.refresh(project)
+
+        folder = await library_folder_factory(name="LinkedFolder", project_id=project.id)
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_delete_folder_with_contents(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
+    ):
+        """library:delete_all keeps full cascade deletion."""
+        folder = await library_folder_factory(name="AdminFolder")
+        await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
+
+        response = await async_client.delete(
+            f"/api/v1/library/folders/{folder.id}",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_delete_operator_folders_empty_only(
+        self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
+    ):
+        """Bulk delete applies the same rule: empty folders go, non-empty are skipped."""
+        empty_folder = await library_folder_factory(name="BulkEmpty")
+        full_folder = await library_folder_factory(name="BulkFull")
+        await library_file_factory(folder_id=full_folder.id, created_by_id=auth_setup["operator2_user"]["id"])
+
+        response = await async_client.post(
+            "/api/v1/library/bulk-delete",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"file_ids": [], "folder_ids": [empty_folder.id, full_folder.id]},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["deleted_folders"] == 1
+        assert result["deleted_files"] == 0
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory):
     async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory):

+ 162 - 0
frontend/src/__tests__/pages/FileManagerFolderDelete.test.tsx

@@ -0,0 +1,162 @@
+/**
+ * Tests for folder deletion permission gating in the File Manager tree (#1781).
+ *
+ * Users with only library:delete_own may delete empty, unlinked, non-external
+ * folders; everything else stays behind library:delete_all.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { FileManagerPage } from '../../pages/FileManagerPage';
+import { setAuthToken } from '../../api/client';
+
+const mockFolders = [
+  {
+    id: 1,
+    name: 'EmptyOne',
+    parent_id: null,
+    file_count: 0,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: false,
+    children: [],
+  },
+  {
+    id: 2,
+    name: 'HasFiles',
+    parent_id: null,
+    file_count: 3,
+    project_id: null,
+    archive_id: null,
+    project_name: null,
+    archive_name: null,
+    is_external: false,
+    children: [],
+  },
+  {
+    id: 3,
+    name: 'LinkedEmpty',
+    parent_id: null,
+    file_count: 0,
+    project_id: 1,
+    archive_id: null,
+    project_name: 'My Project',
+    archive_name: null,
+    is_external: false,
+    children: [],
+  },
+];
+
+function mockAuthUser(permissions: string[]) {
+  setAuthToken('test-token', 'session');
+  server.use(
+    http.get('*/api/v1/auth/status', () =>
+      HttpResponse.json({ auth_enabled: true, requires_setup: false }),
+    ),
+    http.get('*/api/v1/auth/me', () =>
+      HttpResponse.json({
+        id: 7,
+        username: 'operator1',
+        is_admin: false,
+        permissions,
+      }),
+    ),
+  );
+}
+
+async function openFolderMenu(user: ReturnType<typeof userEvent.setup>, folderName: string) {
+  const row = screen.getByText(folderName).parentElement!;
+  const buttons = within(row).getAllByRole('button');
+  // The kebab (MoreVertical) menu toggle is the last button in the row
+  await user.click(buttons[buttons.length - 1]);
+  return row;
+}
+
+describe('FileManager folder deletion gating (#1781)', () => {
+  beforeEach(() => {
+    localStorage.clear();
+    server.use(
+      http.get('/api/v1/library/folders', () => HttpResponse.json(mockFolders)),
+      http.get('/api/v1/library/files', () => HttpResponse.json([])),
+      http.get('/api/v1/library/stats', () =>
+        HttpResponse.json({
+          total_files: 3,
+          total_folders: 3,
+          total_size_bytes: 1024,
+          disk_free_bytes: 10737418240,
+          disk_total_bytes: 107374182400,
+        }),
+      ),
+      http.get('/api/v1/projects/', () => HttpResponse.json([{ id: 1, name: 'My Project', color: '#00ae42' }])),
+      http.get('/api/v1/archives/', () => HttpResponse.json([])),
+    );
+  });
+
+  afterEach(() => {
+    setAuthToken(null);
+  });
+
+  it('enables delete on an empty folder for a delete_own user', async () => {
+    mockAuthUser(['library:read_own', 'library:delete_own']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('EmptyOne')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'EmptyOne');
+    const deleteButton = within(row).getByRole('button', { name: 'Delete' });
+    expect(deleteButton).not.toBeDisabled();
+  });
+
+  it('disables delete on a non-empty folder for a delete_own user, with empty-only tooltip', async () => {
+    mockAuthUser(['library:read_own', 'library:delete_own']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('HasFiles')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'HasFiles');
+    const deleteButton = within(row).getByRole('button', { name: 'Delete' });
+    expect(deleteButton).toBeDisabled();
+    expect(deleteButton).toHaveAttribute('title', 'You can only delete empty folders');
+  });
+
+  it('disables delete on a linked folder for a delete_own user, with no-permission tooltip', async () => {
+    mockAuthUser(['library:read_own', 'library:delete_own']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('LinkedEmpty')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'LinkedEmpty');
+    const deleteButton = within(row).getByRole('button', { name: 'Delete' });
+    expect(deleteButton).toBeDisabled();
+    expect(deleteButton).toHaveAttribute('title', 'You do not have permission to delete folders');
+  });
+
+  it('disables delete entirely for a user without any delete permission', async () => {
+    mockAuthUser(['library:read_own']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('EmptyOne')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'EmptyOne');
+    const deleteButton = within(row).getByRole('button', { name: 'Delete' });
+    expect(deleteButton).toBeDisabled();
+    expect(deleteButton).toHaveAttribute('title', 'You do not have permission to delete folders');
+  });
+
+  it('keeps delete enabled on non-empty folders for a delete_all user', async () => {
+    mockAuthUser(['library:read_all', 'library:delete_all']);
+    render(<FileManagerPage />);
+    await waitFor(() => expect(screen.getByText('HasFiles')).toBeInTheDocument());
+
+    const user = userEvent.setup();
+    const row = await openFolderMenu(user, 'HasFiles');
+    const deleteButton = within(row).getByRole('button', { name: 'Delete' });
+    expect(deleteButton).not.toBeDisabled();
+  });
+});

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -3693,6 +3693,7 @@ export default {
     noPermissionRenameFolder: 'Sie haben keine Berechtigung, Ordner umzubenennen',
     noPermissionRenameFolder: 'Sie haben keine Berechtigung, Ordner umzubenennen',
     noPermissionLinkFolder: 'Sie haben keine Berechtigung, Ordner zu verknüpfen',
     noPermissionLinkFolder: 'Sie haben keine Berechtigung, Ordner zu verknüpfen',
     noPermissionDeleteFolder: 'Sie haben keine Berechtigung, Ordner zu löschen',
     noPermissionDeleteFolder: 'Sie haben keine Berechtigung, Ordner zu löschen',
+    onlyEmptyFoldersDeletable: 'Sie können nur leere Ordner löschen',
     noPermissionPrint: 'Sie haben keine Berechtigung zum Drucken',
     noPermissionPrint: 'Sie haben keine Berechtigung zum Drucken',
     noPermissionSlice: 'Sie haben keine Berechtigung, Dateien zu slicen',
     noPermissionSlice: 'Sie haben keine Berechtigung, Dateien zu slicen',
     noPermissionAddToQueue: 'Sie haben keine Berechtigung, zur Warteschlange hinzuzufügen',
     noPermissionAddToQueue: 'Sie haben keine Berechtigung, zur Warteschlange hinzuzufügen',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -3722,6 +3722,7 @@ export default {
     noPermissionRenameFolder: 'You do not have permission to rename folders',
     noPermissionRenameFolder: 'You do not have permission to rename folders',
     noPermissionLinkFolder: 'You do not have permission to link folders',
     noPermissionLinkFolder: 'You do not have permission to link folders',
     noPermissionDeleteFolder: 'You do not have permission to delete folders',
     noPermissionDeleteFolder: 'You do not have permission to delete folders',
+    onlyEmptyFoldersDeletable: 'You can only delete empty folders',
     noPermissionPrint: 'You do not have permission to print',
     noPermissionPrint: 'You do not have permission to print',
     noPermissionAddToQueue: 'You do not have permission to add to queue',
     noPermissionAddToQueue: 'You do not have permission to add to queue',
     noPermissionSlice: 'You do not have permission to slice files',
     noPermissionSlice: 'You do not have permission to slice files',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -3696,6 +3696,7 @@ export default {
     noPermissionRenameFolder: 'No tiene permiso para renombrar carpetas',
     noPermissionRenameFolder: 'No tiene permiso para renombrar carpetas',
     noPermissionLinkFolder: 'No tiene permiso para vincular carpetas',
     noPermissionLinkFolder: 'No tiene permiso para vincular carpetas',
     noPermissionDeleteFolder: 'No tiene permiso para eliminar carpetas',
     noPermissionDeleteFolder: 'No tiene permiso para eliminar carpetas',
+    onlyEmptyFoldersDeletable: 'Solo puede eliminar carpetas vacías',
     noPermissionPrint: 'No tiene permiso para imprimir',
     noPermissionPrint: 'No tiene permiso para imprimir',
     noPermissionAddToQueue: 'No tiene permiso para añadir a la cola',
     noPermissionAddToQueue: 'No tiene permiso para añadir a la cola',
     noPermissionSlice: 'No tiene permiso para laminar archivos',
     noPermissionSlice: 'No tiene permiso para laminar archivos',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -3682,6 +3682,7 @@ export default {
     noPermissionRenameFolder: 'Pas d\'autorisation renommage',
     noPermissionRenameFolder: 'Pas d\'autorisation renommage',
     noPermissionLinkFolder: 'Pas d\'autorisation lien',
     noPermissionLinkFolder: 'Pas d\'autorisation lien',
     noPermissionDeleteFolder: 'Pas d\'autorisation suppression dossier',
     noPermissionDeleteFolder: 'Pas d\'autorisation suppression dossier',
+    onlyEmptyFoldersDeletable: 'Vous ne pouvez supprimer que les dossiers vides',
     noPermissionPrint: 'Pas d\'autorisation impression',
     noPermissionPrint: 'Pas d\'autorisation impression',
     noPermissionSlice: 'Vous n\'avez pas la permission de découper des fichiers',
     noPermissionSlice: 'Vous n\'avez pas la permission de découper des fichiers',
     noPermissionAddToQueue: 'Pas d\'autorisation file',
     noPermissionAddToQueue: 'Pas d\'autorisation file',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -3681,6 +3681,7 @@ export default {
     noPermissionRenameFolder: 'Non hai il permesso di rinominare cartelle',
     noPermissionRenameFolder: 'Non hai il permesso di rinominare cartelle',
     noPermissionLinkFolder: 'Non hai il permesso di collegare cartelle',
     noPermissionLinkFolder: 'Non hai il permesso di collegare cartelle',
     noPermissionDeleteFolder: 'Non hai il permesso di eliminare cartelle',
     noPermissionDeleteFolder: 'Non hai il permesso di eliminare cartelle',
+    onlyEmptyFoldersDeletable: 'Puoi eliminare solo cartelle vuote',
     noPermissionPrint: 'Non hai il permesso di stampare',
     noPermissionPrint: 'Non hai il permesso di stampare',
     noPermissionSlice: 'Non hai il permesso di sezionare i file',
     noPermissionSlice: 'Non hai il permesso di sezionare i file',
     noPermissionAddToQueue: 'Non hai il permesso di aggiungere alla coda',
     noPermissionAddToQueue: 'Non hai il permesso di aggiungere alla coda',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -3693,6 +3693,7 @@ export default {
     noPermissionRenameFolder: 'フォルダー名を変更する権限がありません',
     noPermissionRenameFolder: 'フォルダー名を変更する権限がありません',
     noPermissionLinkFolder: 'フォルダーをリンクする権限がありません',
     noPermissionLinkFolder: 'フォルダーをリンクする権限がありません',
     noPermissionDeleteFolder: 'フォルダーを削除する権限がありません',
     noPermissionDeleteFolder: 'フォルダーを削除する権限がありません',
+    onlyEmptyFoldersDeletable: '空のフォルダーのみ削除できます',
     noPermissionPrint: '印刷する権限がありません',
     noPermissionPrint: '印刷する権限がありません',
     noPermissionSlice: 'ファイルをスライスする権限がありません',
     noPermissionSlice: 'ファイルをスライスする権限がありません',
     noPermissionAddToQueue: 'キューに追加する権限がありません',
     noPermissionAddToQueue: 'キューに追加する権限がありません',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -3505,6 +3505,7 @@ export default {
     noPermissionRenameFolder: '폴더 이름 변경 권한이 없습니다',
     noPermissionRenameFolder: '폴더 이름 변경 권한이 없습니다',
     noPermissionLinkFolder: '폴더 연결 권한이 없습니다',
     noPermissionLinkFolder: '폴더 연결 권한이 없습니다',
     noPermissionDeleteFolder: '폴더 삭제 권한이 없습니다',
     noPermissionDeleteFolder: '폴더 삭제 권한이 없습니다',
+    onlyEmptyFoldersDeletable: '빈 폴더만 삭제할 수 있습니다',
     noPermissionPrint: '인쇄 권한이 없습니다',
     noPermissionPrint: '인쇄 권한이 없습니다',
     noPermissionAddToQueue: '대기열 추가 권한이 없습니다',
     noPermissionAddToQueue: '대기열 추가 권한이 없습니다',
     noPermissionSlice: '파일 슬라이싱 권한이 없습니다',
     noPermissionSlice: '파일 슬라이싱 권한이 없습니다',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -3681,6 +3681,7 @@ export default {
     noPermissionRenameFolder: 'Você não tem permissão para renomear pastas',
     noPermissionRenameFolder: 'Você não tem permissão para renomear pastas',
     noPermissionLinkFolder: 'Você não tem permissão para vincular pastas',
     noPermissionLinkFolder: 'Você não tem permissão para vincular pastas',
     noPermissionDeleteFolder: 'Você não tem permissão para excluir pastas',
     noPermissionDeleteFolder: 'Você não tem permissão para excluir pastas',
+    onlyEmptyFoldersDeletable: 'Você só pode excluir pastas vazias',
     noPermissionPrint: 'Você não tem permissão para imprimir',
     noPermissionPrint: 'Você não tem permissão para imprimir',
     noPermissionSlice: 'Você não tem permissão para fatiar arquivos',
     noPermissionSlice: 'Você não tem permissão para fatiar arquivos',
     noPermissionAddToQueue: 'Você não tem permissão para adicionar à fila',
     noPermissionAddToQueue: 'Você não tem permissão para adicionar à fila',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -3497,6 +3497,7 @@ export default {
     noPermissionRenameFolder: "У вас нет прав на переименование папок",
     noPermissionRenameFolder: "У вас нет прав на переименование папок",
     noPermissionLinkFolder: "У вас нет прав на связывание папок",
     noPermissionLinkFolder: "У вас нет прав на связывание папок",
     noPermissionDeleteFolder: "У вас нет прав на удаление папок",
     noPermissionDeleteFolder: "У вас нет прав на удаление папок",
+    onlyEmptyFoldersDeletable: "Вы можете удалять только пустые папки",
     noPermissionPrint: "У вас нет прав на запуск печати",
     noPermissionPrint: "У вас нет прав на запуск печати",
     noPermissionAddToQueue: "У вас нет прав на добавление в очередь",
     noPermissionAddToQueue: "У вас нет прав на добавление в очередь",
     noPermissionSlice: "У вас нет прав на нарезку файлов",
     noPermissionSlice: "У вас нет прав на нарезку файлов",

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -3689,6 +3689,7 @@ export default {
     noPermissionRenameFolder: 'Klasörleri yeniden adlandırma izniniz yok',
     noPermissionRenameFolder: 'Klasörleri yeniden adlandırma izniniz yok',
     noPermissionLinkFolder: 'Klasörleri bağlama izniniz yok',
     noPermissionLinkFolder: 'Klasörleri bağlama izniniz yok',
     noPermissionDeleteFolder: 'Klasörleri silme izniniz yok',
     noPermissionDeleteFolder: 'Klasörleri silme izniniz yok',
+    onlyEmptyFoldersDeletable: 'Yalnızca boş klasörleri silebilirsiniz',
     noPermissionPrint: 'Yazdırma izniniz yok',
     noPermissionPrint: 'Yazdırma izniniz yok',
     noPermissionAddToQueue: 'Kuyruğa ekleme izniniz yok',
     noPermissionAddToQueue: 'Kuyruğa ekleme izniniz yok',
     noPermissionSlice: 'Dosyaları dilimleme izniniz yok',
     noPermissionSlice: 'Dosyaları dilimleme izniniz yok',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -3681,6 +3681,7 @@ export default {
     noPermissionRenameFolder: '您没有重命名文件夹的权限',
     noPermissionRenameFolder: '您没有重命名文件夹的权限',
     noPermissionLinkFolder: '您没有链接文件夹的权限',
     noPermissionLinkFolder: '您没有链接文件夹的权限',
     noPermissionDeleteFolder: '您没有删除文件夹的权限',
     noPermissionDeleteFolder: '您没有删除文件夹的权限',
+    onlyEmptyFoldersDeletable: '您只能删除空文件夹',
     noPermissionPrint: '您没有打印的权限',
     noPermissionPrint: '您没有打印的权限',
     noPermissionSlice: '您没有切片文件的权限',
     noPermissionSlice: '您没有切片文件的权限',
     noPermissionAddToQueue: '您没有添加到队列的权限',
     noPermissionAddToQueue: '您没有添加到队列的权限',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -3681,6 +3681,7 @@ export default {
     noPermissionRenameFolder: '您沒有重新命名資料夾的權限',
     noPermissionRenameFolder: '您沒有重新命名資料夾的權限',
     noPermissionLinkFolder: '您沒有連結資料夾的權限',
     noPermissionLinkFolder: '您沒有連結資料夾的權限',
     noPermissionDeleteFolder: '您沒有刪除資料夾的權限',
     noPermissionDeleteFolder: '您沒有刪除資料夾的權限',
+    onlyEmptyFoldersDeletable: '您只能刪除空資料夾',
     noPermissionPrint: '您沒有列印的權限',
     noPermissionPrint: '您沒有列印的權限',
     noPermissionSlice: '您沒有切片檔案的權限',
     noPermissionSlice: '您沒有切片檔案的權限',
     noPermissionAddToQueue: '您沒有新增到佇列的權限',
     noPermissionAddToQueue: '您沒有新增到佇列的權限',

+ 15 - 4
frontend/src/pages/FileManagerPage.tsx

@@ -568,6 +568,17 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
   const hasChildren = folder.children.length > 0;
   const hasChildren = folder.children.length > 0;
   const isLinked = folder.project_id || folder.archive_id;
   const isLinked = folder.project_id || folder.archive_id;
   const isExternal = folder.is_external;
   const isExternal = folder.is_external;
+  // #1781: users with only library:delete_own may delete empty, unlinked,
+  // non-external folders. The backend enforces the same rule and additionally
+  // counts trashed files (invisible here), so a 403 can still come back.
+  const canDeleteFolder =
+    hasPermission('library:delete_all') ||
+    (hasPermission('library:delete_own') && folder.file_count === 0 && !hasChildren && !isExternal && !isLinked);
+  const deleteDisabledTooltip = canDeleteFolder
+    ? undefined
+    : hasPermission('library:delete_own') && !isExternal && !isLinked
+      ? t('fileManager.onlyEmptyFoldersDeletable')
+      : t('fileManager.noPermissionDeleteFolder');
 
 
   return (
   return (
     <div>
     <div>
@@ -669,11 +680,11 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
                 </button>
                 </button>
                 <button
                 <button
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
                   className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
-                    hasPermission('library:delete_all') ? 'text-red-700 dark:text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    canDeleteFolder ? 'text-red-700 dark:text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
                   }`}
                   }`}
-                  onClick={() => { if (hasPermission('library:delete_all')) { onDelete(folder.id); setShowActions(false); } }}
-                  disabled={!hasPermission('library:delete_all')}
-                  title={!hasPermission('library:delete_all') ? t('fileManager.noPermissionDeleteFolder') : undefined}
+                  onClick={() => { if (canDeleteFolder) { onDelete(folder.id); setShowActions(false); } }}
+                  disabled={!canDeleteFolder}
+                  title={deleteDisabledTooltip}
                 >
                 >
                   <Trash2 className="w-3.5 h-3.5" />
                   <Trash2 className="w-3.5 h-3.5" />
                   {t('common.delete')}
                   {t('common.delete')}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CO7zRvej.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CX7cNuer.js"></script>
+    <script type="module" crossorigin src="/assets/index-CO7zRvej.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
     <link rel="stylesheet" crossorigin href="/assets/index-Di24iyOw.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff