Просмотр исходного кода

Security hardening (maziggy/bambuddy-security #12)

maziggy 1 неделя назад
Родитель
Сommit
c39e678204
100 измененных файлов с 1224 добавлено и 28 удалено
  1. 1 1
      backend/app/api/routes/finance.py
  2. 18 0
      backend/app/api/routes/library.py
  3. 3 0
      backend/app/api/routes/makerworld.py
  4. 3 1
      backend/app/api/routes/manyfold.py
  5. 20 3
      backend/app/api/routes/pipeline_runs.py
  6. 13 0
      backend/app/api/routes/users.py
  7. 21 0
      backend/app/core/auth.py
  8. 91 4
      backend/app/core/database.py
  9. 42 0
      backend/app/core/sqlite_fk.py
  10. 15 0
      backend/app/core/websocket.py
  11. 22 2
      backend/app/main.py
  12. 4 2
      backend/app/models/finance.py
  13. 1 1
      backend/app/schemas/finance.py
  14. 3 1
      backend/app/services/finance_balance.py
  15. 28 5
      backend/app/services/print_scheduler.py
  16. 6 0
      backend/app/services/printer_manager.py
  17. 17 0
      backend/app/services/slice_dispatch.py
  18. 195 0
      backend/tests/integration/test_user_delete_id_reuse.py
  19. 302 0
      backend/tests/integration/test_user_delete_in_flight_work.py
  20. 145 0
      backend/tests/integration/test_user_deleted_during_request.py
  21. 62 0
      backend/tests/unit/services/test_upload_failure_reason_2899.py
  22. 35 0
      backend/tests/unit/test_sqlite_fk.py
  23. 76 0
      backend/tests/unit/test_wallet_transactions_user_migration.py
  24. 77 0
      frontend/src/__tests__/pages/FinanceDeletedUser.test.tsx
  25. 1 1
      frontend/src/api/client.ts
  26. 1 0
      frontend/src/i18n/locales/de.ts
  27. 1 0
      frontend/src/i18n/locales/en.ts
  28. 1 0
      frontend/src/i18n/locales/es.ts
  29. 1 0
      frontend/src/i18n/locales/fr.ts
  30. 1 0
      frontend/src/i18n/locales/it.ts
  31. 1 0
      frontend/src/i18n/locales/ja.ts
  32. 1 0
      frontend/src/i18n/locales/ko.ts
  33. 1 0
      frontend/src/i18n/locales/nl.ts
  34. 1 0
      frontend/src/i18n/locales/pt-BR.ts
  35. 1 0
      frontend/src/i18n/locales/ru.ts
  36. 1 0
      frontend/src/i18n/locales/sv.ts
  37. 1 0
      frontend/src/i18n/locales/tr.ts
  38. 1 0
      frontend/src/i18n/locales/uk.ts
  39. 1 0
      frontend/src/i18n/locales/zh-CN.ts
  40. 1 0
      frontend/src/i18n/locales/zh-TW.ts
  41. 6 3
      frontend/src/pages/FinancePage.tsx
  42. 0 0
      static/assets/AdditionalSection-CHyQBftl.js
  43. 0 0
      static/assets/AmsUnitCard-Bxe48BU4.js
  44. 0 0
      static/assets/ArchivesPage-D8uzKf8A.js
  45. 0 0
      static/assets/AssignToAmsModal-Cu3DesKJ.js
  46. 0 0
      static/assets/CamWallPage-BATXeHmc.js
  47. 0 0
      static/assets/CameraDiagnoseModal-B4hPi6Hk.js
  48. 0 0
      static/assets/CameraPage-lhxd4x9F.js
  49. 0 0
      static/assets/CameraWall-C9zsyvLd.js
  50. 0 0
      static/assets/ColumnConfigModal-CPb3e8Mw.js
  51. 0 0
      static/assets/ConfigureAmsSlotModal-Dt3ofGaQ.js
  52. 0 0
      static/assets/ConnectAuthorizePage-CdZErbrU.js
  53. 0 0
      static/assets/ContextMenu-D0hBijF-.js
  54. 0 0
      static/assets/ExternalLinkPage-ClMvS7Y0.js
  55. 0 1
      static/assets/FileManagerPage-Buy0dwk-.js
  56. 0 0
      static/assets/FileUploadModal-D_RzIUV8.js
  57. 0 0
      static/assets/FinancePage-Bw2jziNc.js
  58. 0 0
      static/assets/FinancePage-C7L1sCfN.js
  59. 0 0
      static/assets/GCodeViewerPage-hW8Hhodb.js
  60. 0 0
      static/assets/GroupEditPage-DduIUx76.js
  61. 0 0
      static/assets/ImagePreviewModal-1dojQOP7.js
  62. 0 0
      static/assets/InventoryPage-BQXiEr0e.js
  63. 0 0
      static/assets/LibraryTrashPage-BYQtsUh-.js
  64. 0 0
      static/assets/LoginPage-BuDfqO3p.js
  65. 0 0
      static/assets/MaintenancePage-_5J4yChf.js
  66. 0 0
      static/assets/ModelSourcesPage-C_joM5GI.js
  67. 0 0
      static/assets/NotificationsPage-DGmpIw5G.js
  68. 0 1
      static/assets/PdfPreviewModal-BIba_lt4.js
  69. 0 0
      static/assets/PhotoGalleryModal-DZCSklaS.js
  70. 0 0
      static/assets/PrinterLocationsPage-D2-jUrvu.js
  71. 0 0
      static/assets/PrintersPage-mI6BjZjC.js
  72. 0 0
      static/assets/ProfilesPage-NiAuzTyk.js
  73. 0 0
      static/assets/ProjectDetailPage-DxC6JNvf.js
  74. 0 0
      static/assets/ProjectsPage-D9w5jvkV.js
  75. 0 0
      static/assets/QueuePage-uth6Bxn2.js
  76. 0 0
      static/assets/RichTextEditor-BUzpt7mm.js
  77. 0 0
      static/assets/SettingsPage-DP1duQdC.js
  78. 0 0
      static/assets/SetupPage-ChttfEsS.js
  79. 0 0
      static/assets/SliceModal-BZ-lhiqA.js
  80. 0 0
      static/assets/SpoolBuddyAmsPage-x6ogi_Wk.js
  81. 0 0
      static/assets/SpoolBuddyCalibrationPage-DQtz18l3.js
  82. 0 0
      static/assets/SpoolBuddyDashboard-C71vG_Jt.js
  83. 0 0
      static/assets/SpoolBuddyInventoryPage-D-R8C7Ax.js
  84. 1 1
      static/assets/SpoolBuddySettingsPage-BVP0CF3J.js
  85. 0 0
      static/assets/SpoolBuddyWriteTagPage-IhXqsA2u.js
  86. 0 0
      static/assets/SpreadsheetPreviewModal-B4ezFUyc.js
  87. 0 0
      static/assets/StatsPage-Be1dH9yj.js
  88. 0 0
      static/assets/StreamOverlayPage--BndpAsl.js
  89. 0 0
      static/assets/SystemInfoPage-ViuTbhSS.js
  90. 0 0
      static/assets/de-BBbbJU4T.js
  91. 0 0
      static/assets/de-BfSmrO1D.js
  92. 0 0
      static/assets/es-Bz63egNE.js
  93. 0 0
      static/assets/es-Dn92fmS9.js
  94. 0 0
      static/assets/fr-CmF999mK.js
  95. 0 0
      static/assets/fr-sItnTtep.js
  96. 0 0
      static/assets/index-C1KZGB01.js
  97. 1 1
      static/assets/inventorySearch-DnBIB0ZZ.js
  98. 0 0
      static/assets/it-BwpvLhyg.js
  99. 0 0
      static/assets/it-DEhaofNm.js
  100. 0 0
      static/assets/ja-B-VXWExv.js

+ 1 - 1
backend/app/api/routes/finance.py

@@ -498,7 +498,7 @@ async def get_all_transactions(
     )
     )
 
 
 
 
-async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int) -> None:
+async def _rebuild_wallet_ledger_for_user(db: AsyncSession, user_id: int | None) -> None:
     """Rebuild through the same canonical ledger repair used at startup."""
     """Rebuild through the same canonical ledger repair used at startup."""
     from backend.app.core.database import repair_wallet_ledger_internal
     from backend.app.core.database import repair_wallet_ledger_internal
 
 

+ 18 - 0
backend/app/api/routes/library.py

@@ -29,6 +29,7 @@ from backend.app.core.auth import (
     QueueReviewRequired,
     QueueReviewRequired,
     RequestActor,
     RequestActor,
     RequestPrinterScope,
     RequestPrinterScope,
+    require_actor_still_exists,
     require_media_token_ownership,
     require_media_token_ownership,
     require_ownership_permission,
     require_ownership_permission,
     require_permission_if_auth_enabled,
     require_permission_if_auth_enabled,
@@ -2633,6 +2634,9 @@ async def extract_zip_file(
     folders_created = 0
     folders_created = 0
     folder_cache: dict[str, int] = {}  # path -> folder_id
     folder_cache: dict[str, int] = {}  # path -> folder_id
 
 
+    # The upload may have taken a while; and again before each file below.
+    await require_actor_still_exists(db, actor)
+
     # If create_folder_from_zip is True, create a folder named after the ZIP file
     # If create_folder_from_zip is True, create a folder named after the ZIP file
     zip_folder_id = folder_id
     zip_folder_id = folder_id
     logger.info(
     logger.info(
@@ -2685,6 +2689,9 @@ async def extract_zip_file(
             ]
             ]
 
 
             for zip_path in file_list:
             for zip_path in file_list:
+                # An extract can run for minutes; a user deleted meanwhile must
+                # not have the rest saved under their id (SQLite reuses it).
+                await require_actor_still_exists(db, actor)
                 try:
                 try:
                     # Determine target folder (use zip_folder_id as base if create_folder_from_zip was used)
                     # Determine target folder (use zip_folder_id as base if create_folder_from_zip was used)
                     target_folder_id = zip_folder_id
                     target_folder_id = zip_folder_id
@@ -2874,6 +2881,8 @@ async def extract_zip_file(
 
 
     except zipfile.BadZipFile:
     except zipfile.BadZipFile:
         raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
         raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
+    except HTTPException:
+        raise
     except Exception as e:
     except Exception as e:
         logger.error("ZIP extraction failed: %s", e, exc_info=True)
         logger.error("ZIP extraction failed: %s", e, exc_info=True)
         raise HTTPException(status_code=500, detail=f"ZIP extraction failed: {str(e)}")
         raise HTTPException(status_code=500, detail=f"ZIP extraction failed: {str(e)}")
@@ -3097,6 +3106,7 @@ async def combine_files(
     # The preview is embedded as Metadata/thumbnail.png by combine_parts_to_3mf,
     # The preview is embedded as Metadata/thumbnail.png by combine_parts_to_3mf,
     # so ThreeMFParser picks it up here like any other 3MF's thumbnail. Loading
     # so ThreeMFParser picks it up here like any other 3MF's thumbnail. Loading
     # the combined file back to render one would expand every copy.
     # the combined file back to render one would expand every copy.
+    await require_actor_still_exists(db, actor)  # the combine may have taken a while
     library_file, _ = await save_3mf_bytes_to_library(
     library_file, _ = await save_3mf_bytes_to_library(
         db,
         db,
         file_bytes=content,
         file_bytes=content,
@@ -4896,6 +4906,10 @@ async def slice_and_persist(
     if extra_metadata:
     if extra_metadata:
         metadata.update(extra_metadata)
         metadata.update(extra_metadata)
 
 
+    from backend.app.services.slice_dispatch import slice_dispatch as _dispatch
+
+    # Nobody's, when the user was deleted while it sliced: the id may be someone else's now.
+    current_user_id = _dispatch.owner_now(job_id, current_user_id)
     new_file = LibraryFile(
     new_file = LibraryFile(
         folder_id=folder_id,
         folder_id=folder_id,
         is_external=out_is_external,
         is_external=out_is_external,
@@ -5081,6 +5095,10 @@ async def slice_and_persist_as_archive(
     )
     )
     new_printer_id = None if is_cross_model_reslice else source_archive.printer_id
     new_printer_id = None if is_cross_model_reslice else source_archive.printer_id
 
 
+    from backend.app.services.slice_dispatch import slice_dispatch as _dispatch
+
+    # Nobody's, when the user was deleted while it sliced: the id may be someone else's now.
+    current_user_id = _dispatch.owner_now(job_id, current_user_id)
     new_archive = PrintArchive(
     new_archive = PrintArchive(
         printer_id=new_printer_id,
         printer_id=new_printer_id,
         project_id=source_archive.project_id,
         project_id=source_archive.project_id,

+ 3 - 0
backend/app/api/routes/makerworld.py

@@ -35,6 +35,7 @@ from backend.app.core.auth import (
     ApiKeyActor,
     ApiKeyActor,
     RequestActor,
     RequestActor,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    require_actor_still_exists,
     require_auth_if_enabled,
     require_auth_if_enabled,
     require_permission_if_auth_enabled,
     require_permission_if_auth_enabled,
     security,
     security,
@@ -426,6 +427,8 @@ async def import_instance(
     # there as on the manifest-supplied name.
     # there as on the manifest-supplied name.
     filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename)
     filename = suggested_name if suggested_name.endswith(".3mf") else unquote(download.filename)
 
 
+    # Not saved for a user deleted during the download (SQLite reuses the id).
+    await require_actor_still_exists(db, actor)
     # Credited to the key's owner for an API key. Credential identity is
     # Credited to the key's owner for an API key. Credential identity is
     # resolved inside the provider.
     # resolved inside the provider.
     library_file, was_existing = await save_3mf_bytes_to_library(
     library_file, was_existing = await save_3mf_bytes_to_library(

+ 3 - 1
backend/app/api/routes/manyfold.py

@@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.api.routes.library import save_3mf_bytes_to_library, validate_print_file_upload
 from backend.app.api.routes.library import save_3mf_bytes_to_library, validate_print_file_upload
 from backend.app.api.routes.settings import set_setting
 from backend.app.api.routes.settings import set_setting
-from backend.app.core.auth import ApiKeyActor, RequestActor, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import ApiKeyActor, RequestActor, RequirePermissionIfAuthEnabled, require_actor_still_exists
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
@@ -344,6 +344,8 @@ async def import_file(
 
 
     filename = _library_filename(file["filename"], file_id)
     filename = _library_filename(file["filename"], file_id)
     validate_print_file_upload(filename, data)
     validate_print_file_upload(filename, data)
+    # Not saved for a user deleted during the download (SQLite reuses the id).
+    await require_actor_still_exists(db, actor)
     folder_id = await _import_folder_id(db, body.folder_id, actor)
     folder_id = await _import_folder_id(db, body.folder_id, actor)
     library_file, was_existing = await save_3mf_bytes_to_library(
     library_file, was_existing = await save_3mf_bytes_to_library(
         db,
         db,

+ 20 - 3
backend/app/api/routes/pipeline_runs.py

@@ -40,6 +40,7 @@ from backend.app.core.auth import (
     RequestActor,
     RequestActor,
     RequestPrinterScope,
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    is_auth_enabled,
 )
 )
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
@@ -356,8 +357,12 @@ async def _materialise_run(db: AsyncSession, run: PipelineRun) -> PipelineRunRes
 async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
 async def _publish_run_event(db: AsyncSession, run: PipelineRun) -> None:
     """Broadcast a ``pipeline_run_updated`` event with the full materialised
     """Broadcast a ``pipeline_run_updated`` event with the full materialised
     run. Per-user routing via ``broadcast_to_user`` falls back to a global
     run. Per-user routing via ``broadcast_to_user`` falls back to a global
-    broadcast when ``created_by`` is None (auth-disabled installs)."""
+    broadcast when ``created_by`` is None on auth-disabled installs."""
     try:
     try:
+        # Without a user, broadcast_to_user would send it to everyone. With auth
+        # on, a run whose user was deleted is nobody's to watch (the runs page polls).
+        if run.created_by is None and await is_auth_enabled(db):
+            return
         payload = await _materialise_run(db, run)
         payload = await _materialise_run(db, run)
         await ws_manager.broadcast_to_user(
         await ws_manager.broadcast_to_user(
             run.created_by,
             run.created_by,
@@ -593,11 +598,23 @@ def _make_orchestration_callable(
                     printer_id=printer_id,
                     printer_id=printer_id,
                     target_model=target_model,
                     target_model=target_model,
                     library_file_id=slice_response.library_file_id,
                     library_file_id=slice_response.library_file_id,
-                    created_by_id=creator_user_id,
+                    # From the run as refreshed after the slice, not the id the
+                    # request started with: a user deleted meanwhile is gone
+                    # from it, and SQLite may have given the id to someone else.
+                    created_by_id=run.created_by,
                     status="pending",
                     status="pending",
                     confirm_outcome=confirm_outcome,
                     confirm_outcome=confirm_outcome,
                     # The copies wait for review like any other job of theirs (#1620)
                     # The copies wait for review like any other job of theirs (#1620)
-                    manual_start=review_required,
+                    # A restricted user deleted while it sliced: as delete_user
+                    # stages their ownerless "any model" jobs, or these would run
+                    # on every printer of the model, not only on theirs (#1727).
+                    manual_start=review_required
+                    or (
+                        run.created_by is None
+                        and creator_user_id is not None
+                        and printer_id is None
+                        and not printer_scope.is_unrestricted
+                    ),
                 )
                 )
                 session.add(queue_item)
                 session.add(queue_item)
                 await session.flush()
                 await session.flush()

+ 13 - 0
backend/app/api/routes/users.py

@@ -54,6 +54,8 @@ from backend.app.services.email_service import (
     send_email,
     send_email,
 )
 )
 from backend.app.services.finance_defaults import ensure_user_finance_defaults
 from backend.app.services.finance_defaults import ensure_user_finance_defaults
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.slice_dispatch import slice_dispatch
 from backend.app.utils.library_paths import remove_library_photos_dir
 from backend.app.utils.library_paths import remove_library_photos_dir
 
 
 router = APIRouter(prefix="/users", tags=["users"])
 router = APIRouter(prefix="/users", tags=["users"])
@@ -547,8 +549,19 @@ async def delete_user(
     )
     )
     await db.execute(_update(ConnectedApp).where(ConnectedApp.created_by_id == user_id).values(created_by_id=None))
     await db.execute(_update(ConnectedApp).where(ConnectedApp.created_by_id == user_id).values(created_by_id=None))
 
 
+    # Email 2FA is a settings key named after the id, which no foreign key covers.
+    await db.execute(delete(Settings).where(Settings.key == f"user_{user_id}_email_2fa_enabled"))
+
     await db.delete(user)
     await db.delete(user)
     await db.commit()
     await db.commit()
+    # What still runs for them must not be credited to whoever gets the id next
+    # (SQLite reuses it): a print, a slice, an open dashboard.
+    printer_manager.forget_user(user_id)
+    from backend.app.main import forget_print_creator
+
+    forget_print_creator(user_id)
+    slice_dispatch.forget_owner(user_id)
+    await ws_manager.close_user(user_id)
     await ws_manager.refresh_printer_scopes()
     await ws_manager.refresh_printer_scopes()
 
 
     for file_id in doomed_library_file_ids:
     for file_id in doomed_library_file_ids:

+ 21 - 0
backend/app/core/auth.py

@@ -2071,6 +2071,27 @@ async def get_request_actor(
 RequestActor = Depends(get_request_actor)
 RequestActor = Depends(get_request_actor)
 
 
 
 
+async def actor_still_exists(db: AsyncSession, actor: User | ApiKeyActor | None) -> bool:
+    """Whether the user a request started as still exists.
+
+    A request that downloads or extracts for seconds to minutes writes the id
+    it started with. A user deleted meanwhile leaves it pointing at nobody, or
+    on SQLite, which gives a freed id to the next user created, at someone
+    else; the name tells them apart. No actor (auth off) or a key without an
+    owner has no user to lose.
+    """
+    if actor is None or actor.id is None:
+        return True
+    found = await db.scalar(select(User.id).where(User.id == actor.id, User.username == actor.username))
+    return found is not None
+
+
+async def require_actor_still_exists(db: AsyncSession, actor: User | ApiKeyActor | None) -> None:
+    """Stop a request whose user was deleted while it ran, before it saves anything as theirs."""
+    if not await actor_still_exists(db, actor):
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="This account was deleted")
+
+
 async def get_media_or_request_printer_scope(
 async def get_media_or_request_printer_scope(
     token: str | None = None,
     token: str | None = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
     credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,

+ 91 - 4
backend/app/core/database.py

@@ -340,11 +340,13 @@ async def _repair_sqlite_orphans(conn) -> None:
     what was left (sweep_orphans), after the one domain rule that deviates from
     what was left (sweep_orphans), after the one domain rule that deviates from
     the model's. PostgreSQL enforces the rules itself.
     the model's. PostgreSQL enforces the rules itself.
     """
     """
-    from sqlalchemy import exists, select
+    from sqlalchemy import delete, exists, select
 
 
     from backend.app.core.sqlite_fk import sweep_orphans
     from backend.app.core.sqlite_fk import sweep_orphans
     from backend.app.models.library import LibraryFile
     from backend.app.models.library import LibraryFile
     from backend.app.models.print_queue import PrintQueueItem
     from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.models.settings import Settings
+    from backend.app.models.user import User
     from backend.app.services.library_trash import release_queue_references
     from backend.app.services.library_trash import release_queue_references
 
 
     if not is_sqlite():
     if not is_sqlite():
@@ -369,6 +371,20 @@ async def _repair_sqlite_orphans(conn) -> None:
         await release_queue_references(conn, list(vanished))
         await release_queue_references(conn, list(vanished))
     await sweep_orphans(conn)
     await sweep_orphans(conn)
 
 
+    # Email 2FA is a settings key named after the user id, which no foreign key
+    # covers; the next user given a deleted user's id would be asked for a code.
+    keys = (
+        await conn.execute(
+            select(Settings.key).where(Settings.key.like("user\\_%\\_email\\_2fa\\_enabled", escape="\\"))
+        )
+    ).scalars()
+    user_ids = set((await conn.execute(select(User.id))).scalars().all())
+    gone = [
+        key for key in keys if (m := re.fullmatch(r"user_(\d+)_email_2fa_enabled", key)) and int(m[1]) not in user_ids
+    ]
+    if gone:
+        await conn.execute(delete(Settings).where(Settings.key.in_(gone)))
+
 
 
 async def init_db():
 async def init_db():
     # Import models to register them with SQLAlchemy
     # Import models to register them with SQLAlchemy
@@ -1468,7 +1484,7 @@ async def _migrate_create_finance_tables(conn) -> None:
             """
             """
             CREATE TABLE IF NOT EXISTS wallet_transactions (
             CREATE TABLE IF NOT EXISTS wallet_transactions (
                 id INTEGER PRIMARY KEY,
                 id INTEGER PRIMARY KEY,
-                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                 cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                 cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                 transaction_type VARCHAR(40) NOT NULL,
                 transaction_type VARCHAR(40) NOT NULL,
                 amount NUMERIC(14,2) NOT NULL,
                 amount NUMERIC(14,2) NOT NULL,
@@ -1536,7 +1552,7 @@ async def _migrate_create_finance_tables(conn) -> None:
             """
             """
             CREATE TABLE IF NOT EXISTS wallet_transactions (
             CREATE TABLE IF NOT EXISTS wallet_transactions (
                 id SERIAL PRIMARY KEY,
                 id SERIAL PRIMARY KEY,
-                user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+                user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
                 cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                 cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
                 transaction_type VARCHAR(40) NOT NULL,
                 transaction_type VARCHAR(40) NOT NULL,
                 amount NUMERIC(14,2) NOT NULL,
                 amount NUMERIC(14,2) NOT NULL,
@@ -5502,6 +5518,7 @@ async def _run_migrations(conn):
 
 
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
     await _migrate_add_missing_set_null_fks(conn)
+    await _migrate_wallet_transactions_keep_deleted_users(conn)
     await _migrate_pg_column_types(conn)
     await _migrate_pg_column_types(conn)
     await _migrate_pg_columns_to_model(conn)
     await _migrate_pg_columns_to_model(conn)
 
 
@@ -5692,6 +5709,71 @@ async def _migrate_pg_column_types(conn) -> None:
             await conn.execute(text("SELECT set_config('extra_float_digits', :d, true)"), {"d": digits})
             await conn.execute(text("SELECT set_config('extra_float_digits', :d, true)"), {"d": digits})
 
 
 
 
+async def _migrate_wallet_transactions_keep_deleted_users(conn) -> None:
+    """wallet_transactions.user_id: nullable, ON DELETE SET NULL, as the model says.
+
+    It was NOT NULL with ON DELETE CASCADE, so deleting a user deleted their
+    ledger entries and freed what they had spent of a shared cost centre's
+    budget. SQLite can't alter a column, so the table's SQL in sqlite_master is
+    edited, as for users.password_hash; its foreign keys are off, but the NOT
+    NULL would make the user delete fail on the SET NULL. PostgreSQL drops the
+    NOT NULL and replaces the CASCADE constraint.
+    """
+    from sqlalchemy import text
+
+    if is_sqlite():
+        select_sql = text("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'wallet_transactions'")
+        sql = (await conn.execute(select_sql)).scalar()
+        if not isinstance(sql, str):
+            return
+        # created_by_user_id has no word boundary before "user_id", so stays as it is.
+        new_sql = re.sub(r"(\buser_id\s+INTEGER)\s+NOT\s+NULL", r"\1", sql)
+        new_sql = re.sub(
+            r"(\buser_id\s*\)?\s+(?:INTEGER\s+)?REFERENCES\s+users\s*\(\s*id\s*\)\s+ON\s+DELETE)\s+CASCADE",
+            r"\1 SET NULL",
+            new_sql,
+        )
+        if new_sql == sql:
+            return
+        try:
+            schema_version = (await conn.execute(text("PRAGMA schema_version"))).scalar() or 0
+            await conn.execute(text("PRAGMA writable_schema = ON"))
+            await conn.execute(
+                text("UPDATE sqlite_master SET sql = :sql WHERE type = 'table' AND name = 'wallet_transactions'"),
+                {"sql": new_sql},
+            )
+            # Makes this connection reload the table definition.
+            await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
+            await conn.execute(text("PRAGMA writable_schema = OFF"))
+        except (OperationalError, ProgrammingError) as exc:
+            logger.error(
+                "Could not make wallet_transactions.user_id nullable; deleting a user with wallet "
+                "transactions will fail on this install: %s",
+                exc,
+                exc_info=True,
+            )
+        return
+
+    await _safe_execute(conn, "ALTER TABLE wallet_transactions ALTER COLUMN user_id DROP NOT NULL")
+    cascading = await conn.execute(
+        text(
+            "SELECT c.conname FROM pg_constraint c "
+            "JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) "
+            "WHERE c.contype = 'f' AND c.conrelid = CAST('wallet_transactions' AS regclass) "
+            "AND c.confrelid = CAST('users' AS regclass) AND array_length(c.conkey, 1) = 1 "
+            "AND a.attname = 'user_id' AND c.confdeltype <> 'n'"
+        )
+    )
+    for name in cascading.scalars().all():
+        await conn.execute(text(f'ALTER TABLE wallet_transactions DROP CONSTRAINT "{name}"'))
+        await conn.execute(
+            text(
+                f'ALTER TABLE wallet_transactions ADD CONSTRAINT "{name}" '
+                "FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL"
+            )
+        )
+
+
 # Foreign keys that the migrations adding these columns left out, so a
 # Foreign keys that the migrations adding these columns left out, so a
 # PostgreSQL database older than the column has none: (table, column, parent).
 # PostgreSQL database older than the column has none: (table, column, parent).
 # All ON DELETE SET NULL, as the models declare them.
 # All ON DELETE SET NULL, as the models declare them.
@@ -7171,12 +7253,17 @@ async def repair_wallet_ledger_internal(session: AsyncSession):
                 owner_user_id=center_owner_user_id,
                 owner_user_id=center_owner_user_id,
             )
             )
             if cost_center_id is None:
             if cost_center_id is None:
+                if user_id is None:
+                    # A deleted user's own wallet entry: the wallet is gone, and
+                    # running every deleted user's entries together would
+                    # rewrite their balances with each other's.
+                    continue
                 new_balance = round(user_personal_balances.get(user_id, 0.0) + amount_value, 2)
                 new_balance = round(user_personal_balances.get(user_id, 0.0) + amount_value, 2)
                 user_personal_balances[user_id] = new_balance
                 user_personal_balances[user_id] = new_balance
             else:
             else:
                 new_balance = round(cc_running_balances.get(cost_center_id, 0.0) + amount_value, 2)
                 new_balance = round(cc_running_balances.get(cost_center_id, 0.0) + amount_value, 2)
                 cc_running_balances[cost_center_id] = new_balance
                 cc_running_balances[cost_center_id] = new_balance
-                if affects_personal:
+                if affects_personal and user_id is not None:
                     user_personal_balances[user_id] = round(
                     user_personal_balances[user_id] = round(
                         user_personal_balances.get(user_id, 0.0) + amount_value,
                         user_personal_balances.get(user_id, 0.0) + amount_value,
                         2,
                         2,

+ 42 - 0
backend/app/core/sqlite_fk.py

@@ -78,6 +78,36 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("print_batches", "project_id"),
         ("print_batches", "project_id"),
         ("print_queue", "project_id"),
         ("print_queue", "project_id"),
         ("project_bom_items", "project_id"),
         ("project_bom_items", "project_id"),
+        # users: everything a user owns goes with them or loses its owner, or a
+        # new user given the freed id inherits it (wallet, cost centres, private
+        # folders). Without an owner, folders and private cost centres are left
+        # to admins, not opened to everyone.
+        ("announcement_reads", "user_id"),
+        ("api_keys", "user_id"),
+        ("connected_app_grants", "user_id"),
+        ("connected_apps", "created_by_id"),
+        ("cost_center_members", "user_id"),
+        ("cost_centers", "owner_user_id"),
+        ("file_variant_groups", "created_by_id"),
+        ("library_files", "created_by_id"),
+        ("library_folders", "created_by_id"),
+        ("long_lived_tokens", "user_id"),
+        ("pipeline_runs", "created_by"),
+        ("print_archives", "created_by_id"),
+        ("print_batches", "created_by_id"),
+        ("print_log_entries", "created_by_id"),
+        ("print_queue", "created_by_id"),
+        ("scheduled_dryings", "created_by_id"),
+        ("slicer_pipelines", "created_by"),
+        ("sponsor_toast_state", "user_id"),
+        ("user_email_preferences", "user_id"),
+        ("user_groups", "user_id"),
+        ("user_oidc_links", "user_id"),
+        ("user_otp_codes", "user_id"),
+        ("user_totp", "user_id"),
+        ("user_wallets", "user_id"),
+        ("wallet_transactions", "created_by_user_id"),
+        ("wallet_transactions", "user_id"),
     }
     }
 )
 )
 
 
@@ -212,6 +242,12 @@ def _before_bulk_delete(state: ORMExecuteState) -> None:
     _apply(connection, table.name, select(key).where(where) if where is not None else select(key))
     _apply(connection, table.name, select(key).where(where) if where is not None else select(key))
 
 
 
 
+def _still_not_null(connection: Connection, column: Column) -> bool:
+    """Whether the column is NOT NULL in the database, whatever the model says."""
+    rows = connection.execute(text(f'PRAGMA table_info("{column.table.name}")'))
+    return any(row[1] == column.name and row[3] for row in rows)
+
+
 async def sweep_orphans(conn: Any) -> None:
 async def sweep_orphans(conn: Any) -> None:
     """Repair APPLIED references to rows that no longer exist (SQLite only).
     """Repair APPLIED references to rows that no longer exist (SQLite only).
 
 
@@ -229,6 +265,12 @@ async def sweep_orphans(conn: Any) -> None:
         rules = [
         rules = [
             fk for fk in _applied_foreign_keys() if fk.parent.table.name in tables and fk.column.table.name in tables
             fk for fk in _applied_foreign_keys() if fk.parent.table.name in tables and fk.column.table.name in tables
         ]
         ]
+        refused = {fk for fk in rules if fk.ondelete == "SET NULL" and _still_not_null(connection, fk.parent)}
+        for fk in refused:
+            # A migration that would have dropped it failed (and said so); clearing
+            # the column would fail too, and take the start of the app with it.
+            logger.warning("Not clearing %s.%s: the column is still NOT NULL", fk.parent.table.name, fk.parent.name)
+        rules = [fk for fk in rules if fk not in refused]
         for _ in range(10):
         for _ in range(10):
             changed = 0
             changed = 0
             for fk in rules:
             for fk in rules:

+ 15 - 0
backend/app/core/websocket.py

@@ -109,6 +109,21 @@ class ConnectionManager:
                 if conn in self.active_connections:
                 if conn in self.active_connections:
                     self.active_connections.remove(conn)
                     self.active_connections.remove(conn)
 
 
+    async def close_user(self, user_id: int):
+        """Close a deleted user's sockets, which would otherwise keep receiving
+        the messages of whoever is given their id next."""
+        async with self._lock:
+            theirs = [
+                c for c in self.active_connections if getattr(c.state, "bambuddy_principal_user_id", None) == user_id
+            ]
+            for connection in theirs:
+                self.active_connections.remove(connection)
+        for connection in theirs:
+            try:
+                await connection.close(code=4401)
+            except Exception:  # noqa: BLE001 -- already gone
+                pass
+
     async def refresh_printer_scopes(self):
     async def refresh_printer_scopes(self):
         """Recompute every connection's printer scope (#1727).
         """Recompute every connection's printer scope (#1727).
 
 

+ 22 - 2
backend/app/main.py

@@ -821,6 +821,19 @@ def _build_status_print_keys(printer_id: int, state: PrinterState) -> list[tuple
     return possible_keys
     return possible_keys
 
 
 
 
+async def _print_user_id_if_still_there(db, print_user: dict | None) -> int | None:
+    """The id of who started the print, unless that user has been deleted since.
+
+    Deleting a user forgets them in printer_manager, but a print that ended a
+    moment before still holds them.
+    """
+    if not print_user or print_user.get("user_id") is None:
+        return None
+    from backend.app.models.user import User
+
+    return await db.scalar(select(User.id).where(User.id == print_user["user_id"]))
+
+
 def _is_bambuddy_authorized_print_in_memory(printer_id: int, state: PrinterState) -> bool:
 def _is_bambuddy_authorized_print_in_memory(printer_id: int, state: PrinterState) -> bool:
     """Check the cheap, process-local print ownership signals."""
     """Check the cheap, process-local print ownership signals."""
 
 
@@ -1021,6 +1034,13 @@ async def _record_energy_start(archive, printer_id: int, db, *, context: str = "
         return False
         return False
 
 
 
 
+def forget_print_creator(user_id: int) -> None:
+    """The print-start emails of a deleted user's jobs go to nobody, not to
+    whoever is given the id next (SQLite reuses it)."""
+    for key in [key for key, creator in _expected_print_creators.items() if creator == user_id]:
+        del _expected_print_creators[key]
+
+
 def register_expected_print(
 def register_expected_print(
     printer_id: int,
     printer_id: int,
     filename: str,
     filename: str,
@@ -8029,7 +8049,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 if archive and billing_run_id is None:
                 if archive and billing_run_id is None:
                     billing_run_id = getattr(archive, "billing_run_id", None)
                     billing_run_id = getattr(archive, "billing_run_id", None)
                 if archive and archive.created_by_id is None and _print_user_info:
                 if archive and archive.created_by_id is None and _print_user_info:
-                    archive.created_by_id = _print_user_info.get("user_id")
+                    archive.created_by_id = await _print_user_id_if_still_there(db, _print_user_info)
                     await db.flush()
                     await db.flush()
 
 
                 run_status = data.get("status", "completed")
                 run_status = data.get("status", "completed")
@@ -8124,7 +8144,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 # has no attribution yet, credit the current user. Never
                 # has no attribution yet, credit the current user. Never
                 # overwrite an existing attribution — the original uploader
                 # overwrite an existing attribution — the original uploader
                 # keeps ownership.
                 # keeps ownership.
-                _print_user_id = _print_user_info.get("user_id") if _print_user_info else None
+                _print_user_id = await _print_user_id_if_still_there(db, _print_user_info)
                 if archive.created_by_id is None and _print_user_id is not None:
                 if archive.created_by_id is None and _print_user_id is not None:
                     archive.created_by_id = _print_user_id
                     archive.created_by_id = _print_user_id
                 p_info = printer_manager.get_printer(printer_id)
                 p_info = printer_manager.get_printer(printer_id)

+ 4 - 2
backend/app/models/finance.py

@@ -133,7 +133,9 @@ class WalletTransaction(Base):
     )
     )
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
-    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    # SET NULL, not CASCADE: a deleted user's entries stay in the ledger, or what
+    # they spent of a shared cost centre's budget would be freed again.
+    user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
     cost_center_id: Mapped[int | None] = mapped_column(
     cost_center_id: Mapped[int | None] = mapped_column(
         ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True, index=True
         ForeignKey("cost_centers.id", ondelete="SET NULL"), nullable=True, index=True
     )
     )
@@ -161,7 +163,7 @@ class WalletTransaction(Base):
 
 
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
 
 
-    user: Mapped[User] = relationship(foreign_keys=[user_id])
+    user: Mapped[User | None] = relationship(foreign_keys=[user_id])
     cost_center: Mapped[CostCenter | None] = relationship()
     cost_center: Mapped[CostCenter | None] = relationship()
     created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_user_id])
     created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_user_id])
     print_archive: Mapped[PrintArchive | None] = relationship()
     print_archive: Mapped[PrintArchive | None] = relationship()

+ 1 - 1
backend/app/schemas/finance.py

@@ -15,7 +15,7 @@ class WalletBalanceResponse(BaseModel):
 
 
 class WalletTransactionResponse(BaseModel):
 class WalletTransactionResponse(BaseModel):
     id: int
     id: int
-    user_id: int
+    user_id: int | None  # None once the user is deleted
     cost_center_id: int | None = None
     cost_center_id: int | None = None
     transaction_type: Literal["print_charge", "deposit", "withdraw", "manual_adjustment"]
     transaction_type: Literal["print_charge", "deposit", "withdraw", "manual_adjustment"]
     amount: float
     amount: float

+ 3 - 1
backend/app/services/finance_balance.py

@@ -25,7 +25,7 @@ async def resolve_configured_currency(db: AsyncSession) -> str:
 
 
 
 
 def transaction_affects_personal_balance(
 def transaction_affects_personal_balance(
-    user_id: int,
+    user_id: int | None,
     cost_center_id: int | None,
     cost_center_id: int | None,
     *,
     *,
     is_private: bool = False,
     is_private: bool = False,
@@ -33,6 +33,8 @@ def transaction_affects_personal_balance(
 ) -> bool:
 ) -> bool:
     """Apply the canonical definition to already-loaded transaction data."""
     """Apply the canonical definition to already-loaded transaction data."""
 
 
+    if user_id is None:  # a deleted user's; they have no wallet any more
+        return False
     return cost_center_id is None or (is_private and owner_user_id == user_id)
     return cost_center_id is None or (is_private and owner_user_id == user_id)
 
 
 
 

+ 28 - 5
backend/app/services/print_scheduler.py

@@ -8060,6 +8060,15 @@ class PrintScheduler:
             logger.debug("filament_missing notification failed for item %s: %s", item.id, e)
             logger.debug("filament_missing notification failed for item %s: %s", item.id, e)
         return True
         return True
 
 
+    @staticmethod
+    async def _queue_owner_id(db: AsyncSession, item: PrintQueueItem) -> int | None:
+        """The queue item's owner as the row has it now.
+
+        item.created_by_id was read before preheat and upload, which can take
+        minutes; deleting the user clears the row, not this session's copy.
+        """
+        return await db.scalar(select(PrintQueueItem.created_by_id).where(PrintQueueItem.id == item.id))
+
     async def _propagate_owner_to_printer_manager(self, db: AsyncSession, item: PrintQueueItem) -> None:
     async def _propagate_owner_to_printer_manager(self, db: AsyncSession, item: PrintQueueItem) -> None:
         """Hand the queue item's owner to printer_manager so the
         """Hand the queue item's owner to printer_manager so the
         print-complete callback can credit the user in PrintLogEntry (#1670).
         print-complete callback can credit the user in PrintLogEntry (#1670).
@@ -8069,11 +8078,18 @@ class PrintScheduler:
         in that case the print log row falls back to the existing un-credited
         in that case the print log row falls back to the existing un-credited
         behaviour rather than crashing the dispatch).
         behaviour rather than crashing the dispatch).
         """
         """
-        if not item.created_by_id:
-            return
         from backend.app.models.user import User
         from backend.app.models.user import User
 
 
-        owner = await db.get(User, item.created_by_id)
+        # Read from the row, not item.created_by_id or db.get: both can still
+        # hold a user deleted during the upload, after delete_user forgot them
+        # (on SQLite the id may be someone new's by the time the print ends).
+        owner = (
+            await db.execute(
+                select(User.id, User.username)
+                .join(PrintQueueItem, PrintQueueItem.created_by_id == User.id)
+                .where(PrintQueueItem.id == item.id)
+            )
+        ).first()
         if owner:
         if owner:
             printer_manager.set_current_print_user(item.printer_id, owner.id, owner.username)
             printer_manager.set_current_print_user(item.printer_id, owner.id, owner.username)
 
 
@@ -8576,6 +8592,8 @@ class PrintScheduler:
         # (reporter @Jostxxl). expire_on_commit=False keeps item/printer/archive
         # (reporter @Jostxxl). expire_on_commit=False keeps item/printer/archive
         # readable; the status writes below (upload-failure path and the
         # readable; the status writes below (upload-failure path and the
         # pending->printing CAS) transparently open a fresh transaction.
         # pending->printing CAS) transparently open a fresh transaction.
+        # The owner is read here, after preheat, so the commit releases this read too.
+        toast_uid = await self._queue_owner_id(db, item)
         await db.commit()
         await db.commit()
 
 
         # Delete existing file if present (avoids 553 error on overwrite)
         # Delete existing file if present (avoids 553 error on overwrite)
@@ -8601,7 +8619,6 @@ class PrintScheduler:
 
 
         # Dispatch toast — announce the upload start with the total byte
         # Dispatch toast — announce the upload start with the total byte
         # count so the frontend can render an honest progress bar.
         # count so the frontend can render an honest progress bar.
-        toast_uid = item.created_by_id
         toast_file_name = filename.replace(".gcode.3mf", "").replace(".3mf", "")
         toast_file_name = filename.replace(".gcode.3mf", "").replace(".3mf", "")
         try:
         try:
             total_bytes = file_path.stat().st_size
             total_bytes = file_path.stat().st_size
@@ -8674,6 +8691,10 @@ class PrintScheduler:
         if injected_path and injected_path.exists():
         if injected_path and injected_path.exists():
             injected_path.unlink(missing_ok=True)
             injected_path.unlink(missing_ok=True)
 
 
+        # Again after the upload, which can take minutes: an owner deleted
+        # meanwhile gets no more toasts, and neither does whoever has their id.
+        toast_uid = await self._queue_owner_id(db, item)
+
         if not uploaded:
         if not uploaded:
             # This used to be one string for every upload failure, telling
             # This used to be one string for every upload failure, telling
             # everyone to check the SD card. The client knows which of seven
             # everyone to check the SD card. The client knows which of seven
@@ -8739,7 +8760,9 @@ class PrintScheduler:
                 remote_filename,
                 remote_filename,
                 archive.id,
                 archive.id,
                 ams_mapping=ams_mapping,
                 ams_mapping=ams_mapping,
-                created_by_id=item.created_by_id,
+                # Read after the upload (toast_uid): a user deleted meanwhile gets
+                # no print-start email, and neither does whoever has their id.
+                created_by_id=toast_uid,
                 cost_center_id=item.cost_center_id,
                 cost_center_id=item.cost_center_id,
                 # The plate actually dispatched, not the queue item's raw
                 # The plate actually dispatched, not the queue item's raw
                 # column: on None, `register_expected_print` stores nothing,
                 # column: on None, `register_expected_print` stores nothing,

+ 6 - 0
backend/app/services/printer_manager.py

@@ -434,6 +434,12 @@ class PrinterManager:
         """Clear the current print user when print completes (Issue #206)."""
         """Clear the current print user when print completes (Issue #206)."""
         self._current_print_user.pop(printer_id, None)
         self._current_print_user.pop(printer_id, None)
 
 
+    def forget_user(self, user_id: int) -> None:
+        """A deleted user is credited with none of the prints still running."""
+        for printer_id, user in list(self._current_print_user.items()):
+            if user.get("user_id") == user_id:
+                del self._current_print_user[printer_id]
+
     def is_awaiting_plate_clear(self, printer_id: int) -> bool:
     def is_awaiting_plate_clear(self, printer_id: int) -> bool:
         """Return True when the printer finished/failed a print and is waiting for the
         """Return True when the printer finished/failed a print and is waiting for the
         user to acknowledge the plate is cleared before the queue may dispatch the next job.
         user to acknowledge the plate is cleared before the queue may dispatch the next job.

+ 17 - 0
backend/app/services/slice_dispatch.py

@@ -126,6 +126,23 @@ class SliceDispatchService:
     def get(self, job_id: int) -> SliceJob | None:
     def get(self, job_id: int) -> SliceJob | None:
         return self._jobs.get(job_id)
         return self._jobs.get(job_id)
 
 
+    def forget_owner(self, user_id: int) -> None:
+        """A deleted user's jobs lose their owner (only READ_ALL pollers see them)."""
+        for job in self._jobs.values():
+            if job.owner_id == user_id:
+                job.owner_id = None
+
+    def owner_now(self, job_id: int | None, user_id: int | None) -> int | None:
+        """*user_id*, unless job *job_id* lost that owner while it ran.
+
+        A slice takes minutes. When its user is deleted meanwhile (forget_owner),
+        the result belongs to nobody: the id may already be another user's.
+        """
+        if job_id is None or user_id is None:
+            return user_id
+        job = self._jobs.get(job_id)
+        return user_id if job is not None and job.owner_id == user_id else None
+
     def set_progress(self, job_id: int, progress: dict[str, Any] | None) -> None:
     def set_progress(self, job_id: int, progress: dict[str, Any] | None) -> None:
         """Update the live-progress snapshot for a running job.
         """Update the live-progress snapshot for a running job.
 
 

+ 195 - 0
backend/tests/integration/test_user_delete_id_reuse.py

@@ -0,0 +1,195 @@
+"""A new user must not inherit what belonged to a deleted user.
+
+SQLite runs with foreign keys off and gives the highest freed id to the next
+row, so deleting the newest user and creating another one handed the new user
+everything still attached to that id: wallet balance, transactions, cost centre
+memberships, a private cost centre, private library folders.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select, text
+
+from backend.app.core import sqlite_fk
+from backend.app.core.database import load_all_models
+from backend.app.models.finance import CostCenter, CostCenterMember, UserWallet, WalletTransaction
+from backend.app.models.library import LibraryFolder
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+
+
+async def _user(db, name: str) -> int:
+    user = User(username=name, password_hash="x", role="user")
+    db.add(user)
+    await db.commit()
+    await db.refresh(user)
+    return user.id
+
+
+async def _belongings(db, user_id: int) -> tuple[int, int]:
+    """A wallet, a deposit, a can_print membership, a private cost centre and a private folder."""
+    shared = CostCenter(name="Lab", is_private=False)
+    private = CostCenter(name="Private", is_private=True, owner_user_id=user_id)
+    db.add_all([shared, private])
+    await db.commit()
+    folder = LibraryFolder(name="Secret", created_by_id=user_id)
+    db.add_all(
+        [
+            UserWallet(user_id=user_id, balance=42.0),
+            CostCenterMember(cost_center_id=shared.id, user_id=user_id, can_print=True),
+            WalletTransaction(user_id=user_id, amount=42.0, transaction_type="deposit"),
+            folder,
+        ]
+    )
+    await db.commit()
+    return private.id, folder.id
+
+
+async def _inherited(db, user_id: int) -> dict[str, list]:
+    checks = {
+        "wallet": select(UserWallet.balance).where(UserWallet.user_id == user_id),
+        "transactions": select(WalletTransaction.amount).where(WalletTransaction.user_id == user_id),
+        "memberships": select(CostCenterMember.can_print).where(CostCenterMember.user_id == user_id),
+        "private cost centres": select(CostCenter.name).where(CostCenter.owner_user_id == user_id),
+        "folders": select(LibraryFolder.name).where(LibraryFolder.created_by_id == user_id),
+    }
+    return {name: list((await db.execute(query)).scalars().all()) for name, query in checks.items()}
+
+
+NOTHING = {"wallet": [], "transactions": [], "memberships": [], "private cost centres": [], "folders": []}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_user_with_the_deleted_users_id_inherits_nothing(async_client: AsyncClient, db_session):
+    alice = await _user(db_session, "alice")
+    private_id, folder_id = await _belongings(db_session, alice)
+
+    response = await async_client.delete(f"/api/v1/users/{alice}")
+    assert response.status_code == 204, response.text
+    bob = await _user(db_session, "bob")
+
+    assert bob == alice  # SQLite reuses the freed id; the point of this test
+    assert await _inherited(db_session, bob) == NOTHING
+    # What was alice's private cost centre and folder now has no owner, which
+    # leaves them to admins (not to everyone).
+    owner = (await db_session.execute(select(CostCenter.owner_user_id).where(CostCenter.id == private_id))).scalar()
+    folder = (await db_session.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))).scalar_one()
+    assert owner is None
+    assert folder.created_by_id is None and not folder.shared
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_startup_repair_clears_what_an_earlier_delete_left(db_session):
+    """Installs that already deleted a user before this fix are repaired at start."""
+    alice = await _user(db_session, "alice")
+    await _belongings(db_session, alice)
+    await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice})  # the old way
+    await db_session.commit()
+
+    await sqlite_fk.sweep_orphans(await db_session.connection())
+    await db_session.commit()
+
+    assert await _inherited(db_session, alice) == NOTHING
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_ledger_keeps_a_deleted_users_transactions(async_client: AsyncClient, db_session):
+    """Their charges to a shared cost centre stay spent, and the ledger stays whole."""
+    from backend.app.core.database import repair_wallet_ledger_internal
+
+    alice = await _user(db_session, "alice")
+    lab = CostCenter(name="Lab", is_private=False, total_budget=100.0)
+    db_session.add(lab)
+    await db_session.commit()
+    db_session.add_all(
+        [
+            WalletTransaction(user_id=alice, amount=42.0, balance_after=42.0, transaction_type="deposit"),
+            WalletTransaction(
+                user_id=alice, cost_center_id=lab.id, amount=-30.0, balance_after=-30.0, transaction_type="print_charge"
+            ),
+        ]
+    )
+    await db_session.commit()
+
+    response = await async_client.delete(f"/api/v1/users/{alice}")
+    assert response.status_code == 204, response.text
+    await repair_wallet_ledger_internal(db_session)
+    await db_session.commit()
+
+    rows = (
+        await db_session.execute(
+            select(WalletTransaction.user_id, WalletTransaction.cost_center_id, WalletTransaction.balance_after)
+            .order_by(WalletTransaction.id)
+            .execution_options(populate_existing=True)
+        )
+    ).all()
+    assert [tuple(row) for row in rows] == [(None, None, 42.0), (None, lab.id, -30.0)]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_startup_repair_keeps_the_transactions_of_an_earlier_delete(db_session):
+    alice = await _user(db_session, "alice")
+    db_session.add(WalletTransaction(user_id=alice, amount=-5.0, transaction_type="print_charge"))
+    await db_session.commit()
+    await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice})
+    await db_session.commit()
+
+    await sqlite_fk.sweep_orphans(await db_session.connection())
+    await db_session.commit()
+
+    amounts = (await db_session.execute(select(WalletTransaction.amount, WalletTransaction.user_id))).all()
+    assert [tuple(row) for row in amounts] == [(-5.0, None)]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_next_user_with_the_id_is_not_asked_for_an_email_code(async_client: AsyncClient, db_session):
+    """Email 2FA is a settings key named after the user id, which no foreign key covers."""
+    from backend.app.api.routes.mfa import _get_email_2fa_enabled, _set_email_2fa_enabled
+
+    alice = await _user(db_session, "alice")
+    await _set_email_2fa_enabled(db_session, alice, True)
+    await db_session.commit()
+
+    response = await async_client.delete(f"/api/v1/users/{alice}")
+    assert response.status_code == 204, response.text
+    bob = await _user(db_session, "bob")
+
+    assert bob == alice
+    assert await _get_email_2fa_enabled(db_session, bob) is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_startup_repair_drops_email_2fa_of_users_already_deleted(db_session):
+    from backend.app.api.routes.mfa import _get_email_2fa_enabled, _set_email_2fa_enabled
+    from backend.app.core.database import _repair_sqlite_orphans
+
+    alice, keep = await _user(db_session, "alice"), await _user(db_session, "keep")
+    await _set_email_2fa_enabled(db_session, alice, True)
+    await _set_email_2fa_enabled(db_session, keep, True)
+    await db_session.execute(text("DELETE FROM users WHERE id = :id"), {"id": alice})  # the old way
+    await db_session.commit()
+
+    await _repair_sqlite_orphans(await db_session.connection())
+    await db_session.commit()
+
+    keys = (await db_session.execute(select(Settings.key).where(Settings.key.like("user_%_email_2fa_enabled")))).all()
+    assert [key for (key,) in keys] == [f"user_{keep}_email_2fa_enabled"]
+    assert await _get_email_2fa_enabled(db_session, keep) is True
+
+
+def test_every_reference_to_users_is_applied():
+    """A new table that points at users must not bring the inheritance back."""
+    to_users = {
+        (table.name, fk.parent.name)
+        for table in load_all_models().sorted_tables
+        for fk in table.foreign_keys
+        if fk.column.table.name == "users" and fk.ondelete in ("CASCADE", "SET NULL")
+    }
+    assert to_users
+    assert to_users - sqlite_fk.APPLIED == set()

+ 302 - 0
backend/tests/integration/test_user_delete_in_flight_work.py

@@ -0,0 +1,302 @@
+"""Work a deleted user started must not be credited to whoever gets their id.
+
+A running print, a slice, a pipeline run and an open WebSocket all keep the
+user id they started with, in memory, for minutes or hours. Written after the
+delete, that id is a dangling reference, and on SQLite, which gives a freed id
+to the next user created, it is someone else: they are credited with the print
+and billed for it, own the sliced file, see the slice job and receive the
+deleted user's notifications.
+"""
+
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import delete, select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app import main
+from backend.app.api.routes.library import slice_and_persist, slice_and_persist_as_archive
+from backend.app.api.routes.pipeline_runs import _make_orchestration_callable
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
+from backend.app.core.websocket import ws_manager
+from backend.app.models.archive import PrintArchive
+from backend.app.models.library import LibraryFile
+from backend.app.models.pipeline_run import PipelineJob, PipelineRun
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.models.slicer_pipeline import SlicerPipeline
+from backend.app.models.user import User
+from backend.app.schemas.slicer import SliceRequest, SliceResponse
+from backend.app.services.printer_manager import printer_manager
+from backend.app.services.slice_dispatch import SliceJob, slice_dispatch
+from backend.app.services.slicer_api import SliceResult
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+async def _user(db, name: str) -> int:
+    user = User(username=name, password_hash="x", role="user")
+    db.add(user)
+    await db.commit()
+    return user.id
+
+
+async def _replace_user(db, user_id: int) -> None:
+    """Delete the user and create the next one, which gets the same id on SQLite."""
+    await db.execute(delete(User).where(User.id == user_id))
+    await db.commit()
+    assert await _user(db, "bob") == user_id
+
+
+def _slice_job(owner_id: int) -> SliceJob:
+    job = SliceJob(id=987_654, kind="library_file", source_id=1, source_name="a.3mf", owner_id=owner_id)
+    slice_dispatch._jobs[job.id] = job
+    return job
+
+
+def _slicer_that_runs_while(during):
+    async def slice_(*args, **kwargs):
+        await during()
+        return SliceResult(b"PK\x03\x04 not-a-real-3mf", 3600, 12.5, 4200.0), False
+
+    return patch("backend.app.api.routes.library._run_slicer_with_fallback", AsyncMock(side_effect=slice_))
+
+
+async def test_deleting_a_user_forgets_what_the_app_holds_for_them(async_client: AsyncClient, db_session):
+    alice = await _user(db_session, "alice")
+    printer_manager.set_current_print_user(4242, alice, "alice")
+    main._expected_print_creators[(4242, "a.3mf")] = alice
+    job = _slice_job(alice)
+    socket = SimpleNamespace(
+        state=SimpleNamespace(
+            bambuddy_principal_user_id=alice,
+            bambuddy_scope_principal=("alice", None),
+            bambuddy_printer_scope=None,
+        ),
+        close=AsyncMock(),
+        send_text=AsyncMock(),
+    )
+    ws_manager.active_connections.append(socket)
+    try:
+        response = await async_client.delete(f"/api/v1/users/{alice}")
+        assert response.status_code == 204, response.text
+
+        # The print ends later; nobody is credited with it.
+        assert printer_manager.get_current_print_user(4242) is None
+        # Nor is the print-start email sent to whoever is given the id.
+        assert (4242, "a.3mf") not in main._expected_print_creators
+        # The slice job is no longer theirs, so the next user given the id can't poll it.
+        assert job.owner_id is None
+        # Their open dashboard is closed, not left to receive the next user's messages.
+        socket.close.assert_awaited_once_with(code=4401)
+        assert socket not in ws_manager.active_connections
+    finally:
+        printer_manager.clear_current_print_user(4242)
+        main._expected_print_creators.pop((4242, "a.3mf"), None)
+        slice_dispatch._jobs.pop(job.id, None)
+        if socket in ws_manager.active_connections:
+            ws_manager.active_connections.remove(socket)
+
+
+async def test_a_slice_finishing_after_its_user_was_deleted_belongs_to_nobody(db_session):
+    alice = await _user(db_session, "alice")
+    job = _slice_job(alice)
+    try:
+
+        async def alice_is_deleted_and_bob_created():
+            slice_dispatch.forget_owner(alice)  # what deleting her does
+            await _replace_user(db_session, alice)
+
+        with _slicer_that_runs_while(alice_is_deleted_and_bob_created):
+            response = await slice_and_persist(
+                db_session,
+                model_bytes=b"model",
+                model_filename="a.3mf",
+                folder_id=None,
+                extra_metadata=None,
+                request=SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3),
+                current_user_id=alice,
+                job_id=job.id,
+            )
+    finally:
+        slice_dispatch._jobs.pop(job.id, None)
+
+    sliced = await db_session.get(LibraryFile, response.library_file_id)
+    assert sliced.created_by_id is None
+
+
+async def test_an_archive_slice_finishing_after_its_user_was_deleted_belongs_to_nobody(
+    db_session, monkeypatch, tmp_path
+):
+    from backend.app.core.config import settings
+
+    monkeypatch.setattr(settings, "base_dir", tmp_path)
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    alice = await _user(db_session, "alice")
+    source = PrintArchive(filename="a.3mf", file_path="archive/a.3mf", file_size=1, status="completed")
+    db_session.add(source)
+    await db_session.commit()
+    job = _slice_job(alice)
+    try:
+
+        async def alice_is_deleted_and_bob_created():
+            slice_dispatch.forget_owner(alice)
+            await _replace_user(db_session, alice)
+
+        with _slicer_that_runs_while(alice_is_deleted_and_bob_created):
+            response = await slice_and_persist_as_archive(
+                db_session,
+                model_bytes=b"model",
+                model_filename="a.3mf",
+                request=SliceRequest(printer_preset_id=1, process_preset_id=2, filament_preset_id=3),
+                source_archive=source,
+                current_user_id=alice,
+                job_id=job.id,
+            )
+    finally:
+        slice_dispatch._jobs.pop(job.id, None)
+
+    sliced = await db_session.get(PrintArchive, response.archive_id)
+    assert sliced.created_by_id is None
+
+
+async def _run_pipeline_while_its_creator_is_replaced(
+    db_session, test_engine, tmp_path, *, specific_printer: bool, printer_scope=ALL_PRINTERS
+) -> list[PrintQueueItem]:
+    alice = await _user(db_session, "alice")
+    printer = Printer(name="P", serial_number="SPIPE1", ip_address="192.0.2.9", access_code="x", model="X1C")
+    source = LibraryFile(filename="a.3mf", file_path="library/a.3mf", file_type="3mf", file_size=1)
+    pipeline = SlicerPipeline(
+        name="p",
+        printer_preset_source="local",
+        printer_preset_id="1",
+        process_preset_source="local",
+        process_preset_id="2",
+        filament_presets_json='[{"source": "local", "id": "3"}]',
+    )
+    db_session.add_all([printer, source, pipeline])
+    await db_session.commit()
+    if specific_printer:
+        pipeline.target_kind, pipeline.target_printer_id = "specific_printer", printer.id
+    else:
+        pipeline.target_kind, pipeline.target_model_class = "printer_class", "X1C"
+    run = PipelineRun(pipeline_id=pipeline.id, source_library_file_id=source.id, copies=1, created_by=alice)
+    db_session.add(run)
+    await db_session.commit()
+    db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0))
+    await db_session.commit()
+    model = tmp_path / "a.3mf"
+    model.write_bytes(b"model")
+
+    async def slice_and_persist_while_alice_is_replaced(session, **kwargs):
+        await _replace_user(db_session, alice)
+        sliced = LibraryFile(filename="a.gcode.3mf", file_path="library/a.gcode.3mf", file_type="3mf", file_size=1)
+        session.add(sliced)
+        await session.flush()
+        return SliceResponse(
+            library_file_id=sliced.id, name="a", print_time_seconds=1, filament_used_g=1.0, filament_used_mm=1.0
+        )
+
+    orchestrate = _make_orchestration_callable(
+        run_id=run.id,
+        pipeline_id=pipeline.id,
+        src_kind="library_file",
+        src_id=source.id,
+        src_filename="a.3mf",
+        src_path=Path(model),
+        creator_user_id=alice,
+        copies=1,
+        printer_scope=printer_scope,
+    )
+    with (
+        patch(
+            "backend.app.api.routes.pipeline_runs.async_session",
+            async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False),
+        ),
+        patch("backend.app.api.routes.library.slice_and_persist", new=slice_and_persist_while_alice_is_replaced),
+        patch("backend.app.api.routes.pipeline_runs._publish_run_event", new=AsyncMock()),
+    ):
+        await orchestrate(1)
+
+    statement = select(PrintQueueItem).execution_options(populate_existing=True)
+    return list((await db_session.execute(statement)).scalars().all())
+
+
+async def test_a_pipeline_run_finishing_after_its_user_was_deleted_queues_ownerless_copies(
+    db_session, test_engine, tmp_path
+):
+    copies = await _run_pipeline_while_its_creator_is_replaced(db_session, test_engine, tmp_path, specific_printer=True)
+
+    assert [copy.created_by_id for copy in copies] == [None]
+
+
+async def test_any_model_copies_of_a_deleted_restricted_user_wait_for_an_admin(db_session, test_engine, tmp_path):
+    """As delete_user does with such jobs that already exist: without an owner the
+    scheduler would run them on any printer of the model, not only on theirs."""
+    copies = await _run_pipeline_while_its_creator_is_replaced(
+        db_session, test_engine, tmp_path, specific_printer=False, printer_scope=PrinterScope(frozenset({1}))
+    )
+
+    assert [(copy.printer_id, copy.created_by_id, copy.manual_start) for copy in copies] == [(None, None, True)]
+
+
+async def test_an_ownerless_run_is_not_announced_to_everyone(db_session):
+    """broadcast_to_user(None) goes to every connection; with auth on, a run whose
+    user was deleted is nobody's to watch (the runs page polls)."""
+    from backend.app.api.routes.pipeline_runs import _publish_run_event
+    from backend.app.api.routes.settings import set_setting
+
+    run = PipelineRun(copies=1, created_by=None)
+    db_session.add(run)
+    await db_session.commit()
+
+    with patch("backend.app.api.routes.pipeline_runs.ws_manager.broadcast_to_user", new=AsyncMock()) as sent:
+        await _publish_run_event(db_session, run)
+        assert sent.await_count == 1  # auth off: one user, everything is theirs
+        await set_setting(db_session, "auth_enabled", "true")
+        await db_session.commit()
+        await _publish_run_event(db_session, run)
+        assert sent.await_count == 1
+
+
+async def test_a_finished_print_is_credited_to_its_user_unless_they_were_deleted(db_session):
+    from backend.app.main import _print_user_id_if_still_there
+
+    alice = await _user(db_session, "alice")
+    # Renamed while the print ran: still theirs.
+    assert await _print_user_id_if_still_there(db_session, {"user_id": alice, "username": "old name"}) == alice
+    await db_session.execute(delete(User).where(User.id == alice))
+    await db_session.commit()
+    assert await _print_user_id_if_still_there(db_session, {"user_id": alice, "username": "alice"}) is None
+
+
+async def test_a_queue_print_whose_user_was_deleted_during_the_upload_is_nobodys(db_session, test_engine, monkeypatch):
+    """The scheduler loaded the user and the item before the upload; after it,
+    neither may bring the deleted user back (and with them, whoever gets the id)."""
+    from backend.app.services import print_scheduler as scheduler_module
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    alice = await _user(db_session, "alice")
+    printer = Printer(name="P", serial_number="SUPL1", ip_address="192.0.2.10", access_code="x")
+    db_session.add(printer)
+    await db_session.commit()
+    item = PrintQueueItem(printer_id=printer.id, position=1, created_by_id=alice)
+    db_session.add(item)
+    await db_session.commit()
+    assert await db_session.get(User, alice) is not None  # held by the dispatch, as _start_print does
+
+    other = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    async with other() as admin_db:  # meanwhile, through the API
+        await admin_db.execute(delete(User).where(User.id == alice))
+        await admin_db.commit()
+        admin_db.add(User(username="bob", password_hash="x", role="user"))
+        await admin_db.commit()
+
+    credited: list = []
+    monkeypatch.setattr(scheduler_module.printer_manager, "set_current_print_user", lambda *args: credited.append(args))
+    await PrintScheduler()._propagate_owner_to_printer_manager(db_session, item)
+
+    assert credited == []

+ 145 - 0
backend/tests/integration/test_user_deleted_during_request.py

@@ -0,0 +1,145 @@
+"""A request whose user is deleted while it runs saves nothing under their id.
+
+An import downloads for seconds, a ZIP extract or a combine runs for minutes,
+and each holds the user it started with. Saved afterwards, that id points at
+nobody, and on SQLite, which gives a freed id to the next user created, at
+someone else: they would own the files and private folders. These requests stop
+with 401 instead. The tests hand each route the user as the request holds it,
+already deleted, with bob created on the freed id.
+"""
+
+import io
+import zipfile
+from unittest.mock import AsyncMock, patch
+
+import pytest
+import trimesh
+from httpx import AsyncClient
+from sqlalchemy import delete, select
+
+from backend.app.core.auth import ApiKeyActor, actor_still_exists, get_request_actor
+from backend.app.main import app
+from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.user import User
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+async def _user(db, name: str) -> User:
+    user = User(username=name, password_hash="x", role="user")
+    db.add(user)
+    await db.commit()
+    return user
+
+
+@pytest.fixture
+async def deleted_alice(db_session):
+    """Alice as a running request holds her: deleted since, and bob has her id."""
+    alice = await _user(db_session, "alice")
+    # An admin, so no ownership check turns the request away before the write.
+    held = User(id=alice.id, username="alice", password_hash="x", role="admin")
+    await db_session.execute(delete(User).where(User.id == alice.id))
+    await db_session.commit()
+    assert (await _user(db_session, "bob")).id == alice.id
+    try:
+        yield lambda: app.dependency_overrides.__setitem__(get_request_actor, lambda: held)
+    finally:
+        app.dependency_overrides.pop(get_request_actor, None)
+
+
+async def _library(db) -> tuple[list[int], list[int]]:
+    files = (await db.execute(select(LibraryFile.created_by_id))).scalars().all()
+    folders = (await db.execute(select(LibraryFolder.created_by_id))).scalars().all()
+    return list(files), list(folders)
+
+
+async def test_the_check_knows_who_is_still_there(db_session):
+    alice = await _user(db_session, "alice")
+    assert await actor_still_exists(db_session, alice)
+    assert await actor_still_exists(db_session, None)  # auth off: nobody to lose
+    keyless = ApiKeyActor.__new__(ApiKeyActor)
+    keyless.id, keyless.username = None, None
+    assert await actor_still_exists(db_session, keyless)  # a key without an owner
+
+    held = User(id=alice.id, username="alice")
+    await db_session.execute(delete(User).where(User.id == alice.id))
+    await db_session.commit()
+    assert not await actor_still_exists(db_session, held)
+    await _user(db_session, "bob")  # given alice's id
+    assert not await actor_still_exists(db_session, held)
+
+
+async def test_a_zip_extract_stops(async_client: AsyncClient, db_session, deleted_alice):
+    deleted_alice()
+    archive = io.BytesIO()
+    with zipfile.ZipFile(archive, "w") as zf:
+        zf.writestr("sub/a.3mf", b"PK\x03\x04 a")
+        zf.writestr("sub/b.3mf", b"PK\x03\x04 b")
+
+    response = await async_client.post(
+        "/api/v1/library/files/extract-zip",
+        files={"file": ("models.zip", archive.getvalue(), "application/zip")},
+        params={"generate_stl_thumbnails": "false"},
+    )
+
+    assert response.status_code == 401, response.text
+    assert await _library(db_session) == ([], [])
+
+
+async def test_a_combine_stops(async_client: AsyncClient, db_session, deleted_alice):
+    stl = trimesh.creation.box((10, 10, 10)).export(file_type="stl")
+    uploaded = await async_client.post(
+        "/api/v1/library/files",
+        files={"file": ("box.stl", stl, "application/octet-stream")},
+        params={"generate_stl_thumbnails": "false"},
+    )
+    assert uploaded.status_code == 200, uploaded.text
+
+    deleted_alice()
+    response = await async_client.post(
+        "/api/v1/library/files/combine", json={"items": [{"file_id": uploaded.json()["id"]}], "filename": "Plate"}
+    )
+
+    assert response.status_code == 401, response.text
+    assert (await _library(db_session))[0] == [None]  # only the source
+
+
+async def test_a_makerworld_import_stops(async_client: AsyncClient, db_session, deleted_alice):
+    from backend.app.services.model_providers.base import ProviderDownload
+    from backend.tests.integration.test_makerworld_apikey_auth import _download_info, _fake_service
+
+    deleted_alice()
+    service = _fake_service(
+        get_download=_download_info(),
+        download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
+    )
+    with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=service)):
+        response = await async_client.post("/api/v1/makerworld/import", json={"model_id": 1400373})
+
+    assert response.status_code == 401, response.text
+    assert (await _library(db_session))[0] == []
+
+
+async def test_a_manyfold_import_stops(async_client: AsyncClient, db_session, deleted_alice, monkeypatch):
+    from backend.app.api.routes import manyfold as routes
+    from backend.app.services.model_providers.manyfold import service as svc
+    from backend.tests._fixtures.manyfold import BASE, STL, FakeFile, FakeManyfold
+
+    fake = FakeManyfold(client_secret="app-secret")
+    fake.add_model("cube01", "Calibration Cube", {"f1": FakeFile("cube.stl", "model/stl", STL)})
+    real = routes.ManyfoldService
+    monkeypatch.setattr(routes, "ManyfoldService", lambda config: real(config, client=fake.client()))
+    svc.clear_token_cache()
+    try:
+        connected = await async_client.put(
+            "/api/v1/manyfold/config",
+            json={"url": f"{BASE}/", "client_id": fake.client_id, "client_secret": "app-secret"},
+        )
+        assert connected.status_code == 200, connected.text
+        deleted_alice()
+        response = await async_client.post("/api/v1/manyfold/import", json={"model_id": "cube01", "file_id": "f1"})
+    finally:
+        svc.clear_token_cache()
+
+    assert response.status_code == 401, response.text
+    assert await _library(db_session) == ([], [])

+ 62 - 0
backend/tests/unit/services/test_upload_failure_reason_2899.py

@@ -426,3 +426,65 @@ class TestWhatTheQueueEntrySays:
         assert "server log" in message, message
         assert "server log" in message, message
         assert "SD card" not in message, message
         assert "SD card" not in message, message
         assert reason == message
         assert reason == message
+
+
+class TestAnOwnerDeletedDuringTheUpload:
+    async def test_the_failure_toast_goes_to_nobody(self, dispatch_case):
+        """The dispatch read the owner before the upload. Deleted meanwhile, and
+        their id given to bob (SQLite reuses it), the toast must not go to bob."""
+        from sqlalchemy import delete
+
+        import backend.app.services.print_scheduler as scheduler_module
+        from backend.app.models.print_queue import PrintQueueItem
+        from backend.app.models.user import User
+        from backend.app.services.print_scheduler import PrintScheduler
+        from backend.tests._fixtures.background_tasks import discarding_spawn_patch
+
+        async with dispatch_case.session_maker() as db:
+            alice = User(username="alice", password_hash="x", role="user")
+            db.add(alice)
+            await db.commit()
+            (await db.get(PrintQueueItem, dispatch_case.item_id)).created_by_id = alice.id
+            await db.commit()
+            alice_id = alice.id
+
+        async def _upload(*_args, **kwargs):
+            async with dispatch_case.session_maker() as admin_db:
+                await admin_db.execute(delete(User).where(User.id == alice_id))
+                await admin_db.commit()
+                admin_db.add(User(username="bob", password_hash="x", role="user"))
+                await admin_db.commit()
+            kwargs["failure"].failure = FtpFailure(FtpFailureKind.STORAGE, "553 Could not create file.", "553")
+            return False
+
+        failed = AsyncMock()
+        scheduler = PrintScheduler()
+        async with dispatch_case.session_maker() as db:
+            item = await db.get(PrintQueueItem, dispatch_case.item_id)
+            assert item.created_by_id == alice_id
+            patches = [
+                patch.object(scheduler_module.settings, "base_dir", dispatch_case.base_dir),
+                patch(
+                    "backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)
+                ),
+                patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
+                patch(
+                    "backend.app.services.print_scheduler.get_ftp_retry_settings",
+                    AsyncMock(return_value=(False, 0, 0, 1.0)),
+                ),
+                patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
+                patch("backend.app.services.print_scheduler.upload_file_async", _upload),
+                patch("backend.app.services.print_scheduler.notification_service.on_queue_job_failed", AsyncMock()),
+                patch("backend.app.services.print_scheduler.ws_manager.send_queue_item_failed", failed),
+                discarding_spawn_patch(),
+                patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
+                patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
+                patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
+            ]
+            with ExitStack() as stack:
+                for p in patches:
+                    stack.enter_context(p)
+                await scheduler._start_print(db, item)
+
+        assert failed.await_count == 1
+        assert failed.await_args.kwargs["user_id"] is None

+ 35 - 0
backend/tests/unit/test_sqlite_fk.py

@@ -207,3 +207,38 @@ def test_every_listed_reference_is_a_model_foreign_key_with_a_rule():
     }
     }
     assert sqlite_fk.APPLIED - with_rule == set()
     assert sqlite_fk.APPLIED - with_rule == set()
     assert len(sqlite_fk._applied_foreign_keys()) == len(sqlite_fk.APPLIED)
     assert len(sqlite_fk._applied_foreign_keys()) == len(sqlite_fk.APPLIED)
+
+
+def test_every_listed_set_null_column_can_hold_null():
+    """A SET NULL rule on a NOT NULL column would make the delete itself fail."""
+    not_nullable = {
+        (table.name, fk.parent.name)
+        for table in load_all_models().sorted_tables
+        for fk in table.foreign_keys
+        if (table.name, fk.parent.name) in sqlite_fk.APPLIED and fk.ondelete == "SET NULL" and not fk.parent.nullable
+    }
+    assert not_nullable == set()
+
+
+@pytest.mark.asyncio
+async def test_the_sweep_skips_a_set_null_its_column_still_refuses():
+    """If a migration couldn't drop a NOT NULL, the sweep must not stop the app from starting."""
+    from sqlalchemy.ext.asyncio import create_async_engine
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    try:
+        async with engine.begin() as conn:
+            await conn.execute(text("CREATE TABLE users (id INTEGER PRIMARY KEY)"))
+            await conn.execute(
+                text(
+                    "CREATE TABLE wallet_transactions (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, "
+                    "cost_center_id INTEGER, created_by_user_id INTEGER, print_archive_id INTEGER, print_queue_id INTEGER)"
+                )
+            )
+            await conn.execute(text("INSERT INTO wallet_transactions (id, user_id) VALUES (1, 7)"))
+
+            await sqlite_fk.sweep_orphans(conn)
+
+            assert (await conn.execute(text("SELECT user_id FROM wallet_transactions"))).scalar_one() == 7
+    finally:
+        await engine.dispose()

+ 76 - 0
backend/tests/unit/test_wallet_transactions_user_migration.py

@@ -0,0 +1,76 @@
+"""wallet_transactions.user_id becomes nullable, ON DELETE SET NULL, on existing SQLite databases.
+
+It was NOT NULL with ON DELETE CASCADE: deleting a user deleted their ledger
+entries, which freed what they had spent of a shared cost centre's budget.
+The entries stay now, without a user. On SQLite the NOT NULL has to go from
+tables made before, or the delete fails on it. (PostgreSQL is checked by
+scripts/check_postgres_upgrade.py.)
+"""
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core import database
+
+# As create_all made the table from the old model ...
+CREATED_BY_THE_MODEL = """
+CREATE TABLE wallet_transactions (
+\tid INTEGER NOT NULL,
+\tuser_id INTEGER NOT NULL,
+\tcost_center_id INTEGER,
+\ttransaction_type VARCHAR(40) NOT NULL,
+\tamount NUMERIC(14, 2) NOT NULL,
+\tcreated_by_user_id INTEGER,
+\tPRIMARY KEY (id),
+\tFOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE,
+\tFOREIGN KEY(cost_center_id) REFERENCES cost_centers (id) ON DELETE SET NULL,
+\tFOREIGN KEY(created_by_user_id) REFERENCES users (id) ON DELETE SET NULL
+)
+"""
+
+# ... and as the finance migration's own CREATE TABLE made it.
+CREATED_BY_THE_MIGRATION = """
+CREATE TABLE wallet_transactions (
+    id INTEGER PRIMARY KEY,
+    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+    cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
+    transaction_type VARCHAR(40) NOT NULL,
+    amount NUMERIC(14,2) NOT NULL,
+    created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
+)
+"""
+
+
+@pytest.fixture(autouse=True)
+def sqlite_dialect(monkeypatch):
+    monkeypatch.setattr(database, "is_sqlite", lambda: True)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("legacy", [CREATED_BY_THE_MODEL, CREATED_BY_THE_MIGRATION], ids=["model", "migration"])
+async def test_the_user_column_takes_null_and_says_set_null(legacy):
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+    try:
+        async with engine.begin() as conn:
+            await conn.execute(text(legacy))
+            await conn.execute(
+                text(
+                    "INSERT INTO wallet_transactions (id, user_id, transaction_type, amount) VALUES (1, 7, 'deposit', 5)"
+                )
+            )
+
+            await database._migrate_wallet_transactions_keep_deleted_users(conn)
+            await database._migrate_wallet_transactions_keep_deleted_users(conn)  # a restart changes nothing
+
+            await conn.execute(text("UPDATE wallet_transactions SET user_id = NULL WHERE id = 1"))
+            assert (await conn.execute(text("SELECT user_id, amount FROM wallet_transactions"))).one() == (None, 5)
+            sql = (
+                await conn.execute(text("SELECT sql FROM sqlite_master WHERE name = 'wallet_transactions'"))
+            ).scalar_one()
+            assert "CASCADE" not in sql
+            # Only the user column changed.
+            assert "transaction_type VARCHAR(40) NOT NULL" in sql
+            assert sql.count("ON DELETE SET NULL") == 3
+    finally:
+        await engine.dispose()

+ 77 - 0
frontend/src/__tests__/pages/FinanceDeletedUser.test.tsx

@@ -0,0 +1,77 @@
+/**
+ * What a deleted user leaves in the finance admin view: ledger entries without
+ * a user (kept, so a shared cost centre stays spent) and private cost centres
+ * without an owner. Both say so, rather than "User #null" or "Personal", which
+ * read as the viewing admin's own.
+ */
+
+import { describe, it, expect } 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 { FinancePage } from '../../pages/FinancePage';
+import { server } from '../mocks/server';
+
+const orphanedCentre = {
+  id: 9,
+  name: 'Alice private',
+  is_private: true,
+  owner_user_id: null,
+  is_active: true,
+  total_balance: 0,
+  total_budget: null,
+  monthly_budget: null,
+  budget_mode: 'none',
+  budget_limit: null,
+  budget_used: null,
+  budget_available: null,
+  can_print: true,
+};
+
+const orphanedEntry = {
+  id: 1,
+  user_id: null,
+  cost_center_id: null,
+  transaction_type: 'deposit',
+  amount: 42,
+  balance_after: 42,
+  description: 'Left by a deleted user',
+  created_by_user_id: null,
+  print_run_id: null,
+  print_archive_id: null,
+  print_queue_id: null,
+  created_at: '2026-10-11T10:00:00',
+};
+
+describe('FinancePage after a user was deleted', () => {
+  it('labels their ledger entries and their private cost centre as a deleted user', async () => {
+    server.use(
+      http.get('*/api/v1/settings/ui-flags', () => HttpResponse.json({ billing_enabled: true, currency: 'USD' })),
+      http.get('*/api/v1/finance/me/balance', () =>
+        HttpResponse.json({ user_id: 1, balance: 0, currency: 'USD', updated_at: null }),
+      ),
+      http.get('*/api/v1/finance/me/transactions', () => HttpResponse.json({ items: [], total: 0 })),
+      http.get('*/api/v1/finance/transactions', () =>
+        HttpResponse.json({ items: [orphanedEntry], total: 1, limit: 50, offset: 0 }),
+      ),
+      http.get('*/api/v1/finance/cost-centers/mine', () => HttpResponse.json([])),
+      http.get('*/api/v1/finance/cost-centers', () => HttpResponse.json([orphanedCentre])),
+      http.get('*/api/v1/users/slim', () => HttpResponse.json([])),
+    );
+
+    render(<FinancePage />);
+    await userEvent.click(await screen.findByText('Admin view'));
+
+    const entry = (await screen.findByText('Left by a deleted user')).closest('tr') as HTMLElement;
+    expect(within(entry).getByText('Deleted user')).toBeInTheDocument();
+    await waitFor(() => {
+      const rows = screen
+        .getAllByText('Alice private')
+        .map((cell) => cell.closest('tr'))
+        .filter((row): row is HTMLTableRowElement => row !== null);
+      expect(rows.some((row) => within(row).queryByText('Deleted user'))).toBe(true);
+    });
+    expect(screen.queryByText(/User #null/)).not.toBeInTheDocument();
+  });
+});

+ 1 - 1
frontend/src/api/client.ts

@@ -4378,7 +4378,7 @@ export type WalletTransactionType = 'print_charge' | 'deposit' | 'withdraw' | 'm
 
 
 export interface WalletTransaction {
 export interface WalletTransaction {
   id: number;
   id: number;
-  user_id: number;
+  user_id: number | null; // null once the user is deleted
   cost_center_id: number | null;
   cost_center_id: number | null;
   transaction_type: WalletTransactionType;
   transaction_type: WalletTransactionType;
   amount: number;
   amount: number;

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'Kostenstelle',
     costCenter: 'Kostenstelle',
     balanceAfter: 'Kontostand danach',
     balanceAfter: 'Kontostand danach',
     userWithId: 'Benutzer #{{id}}',
     userWithId: 'Benutzer #{{id}}',
+    deletedUser: 'Gelöschter Benutzer',
     partial: 'Teilweise',
     partial: 'Teilweise',
     editTransaction: 'Transaktion bearbeiten',
     editTransaction: 'Transaktion bearbeiten',
     selectCostCenter: 'Kostenstelle auswählen...',
     selectCostCenter: 'Kostenstelle auswählen...',

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'Cost center',
     costCenter: 'Cost center',
     balanceAfter: 'Balance after',
     balanceAfter: 'Balance after',
     userWithId: 'User #{{id}}',
     userWithId: 'User #{{id}}',
+    deletedUser: 'Deleted user',
     partial: 'Partial',
     partial: 'Partial',
     editTransaction: 'Edit Transaction',
     editTransaction: 'Edit Transaction',
     selectCostCenter: 'Select cost center...',
     selectCostCenter: 'Select cost center...',

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'Centro de costes',
     costCenter: 'Centro de costes',
     balanceAfter: 'Saldo posterior',
     balanceAfter: 'Saldo posterior',
     userWithId: 'Usuario n.º {{id}}',
     userWithId: 'Usuario n.º {{id}}',
+    deletedUser: 'Usuario eliminado',
     partial: 'Parcial',
     partial: 'Parcial',
     editTransaction: 'Editar transacción',
     editTransaction: 'Editar transacción',
     selectCostCenter: 'Seleccionar centro de costes...',
     selectCostCenter: 'Seleccionar centro de costes...',

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'Centre de coûts',
     costCenter: 'Centre de coûts',
     balanceAfter: 'Solde après',
     balanceAfter: 'Solde après',
     userWithId: 'Utilisateur #{{id}}',
     userWithId: 'Utilisateur #{{id}}',
+    deletedUser: 'Utilisateur supprimé',
     partial: 'Partiel',
     partial: 'Partiel',
     editTransaction: 'Modifier la transaction',
     editTransaction: 'Modifier la transaction',
     selectCostCenter: 'Sélectionner un centre de coûts...',
     selectCostCenter: 'Sélectionner un centre de coûts...',

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'Centro di costo',
     costCenter: 'Centro di costo',
     balanceAfter: 'Saldo successivo',
     balanceAfter: 'Saldo successivo',
     userWithId: 'Utente #{{id}}',
     userWithId: 'Utente #{{id}}',
+    deletedUser: 'Utente eliminato',
     partial: 'Parziale',
     partial: 'Parziale',
     editTransaction: 'Modifica transazione',
     editTransaction: 'Modifica transazione',
     selectCostCenter: 'Seleziona centro di costo...',
     selectCostCenter: 'Seleziona centro di costo...',

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'コストセンター',
     costCenter: 'コストセンター',
     balanceAfter: '調整後残高',
     balanceAfter: '調整後残高',
     userWithId: 'ユーザー #{{id}}',
     userWithId: 'ユーザー #{{id}}',
+    deletedUser: '削除されたユーザー',
     partial: '部分',
     partial: '部分',
     editTransaction: 'トランザクションを編集',
     editTransaction: 'トランザクションを編集',
     selectCostCenter: 'コストセンターを選択...',
     selectCostCenter: 'コストセンターを選択...',

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

@@ -199,6 +199,7 @@ export default {
     costCenter: '비용 센터',
     costCenter: '비용 센터',
     balanceAfter: '거래 후 잔액',
     balanceAfter: '거래 후 잔액',
     userWithId: '사용자 #{{id}}',
     userWithId: '사용자 #{{id}}',
+    deletedUser: '삭제된 사용자',
     partial: '일부',
     partial: '일부',
     editTransaction: '거래 편집',
     editTransaction: '거래 편집',
     selectCostCenter: '비용 센터 선택...',
     selectCostCenter: '비용 센터 선택...',

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'Kostenplaats',
     costCenter: 'Kostenplaats',
     balanceAfter: 'Saldo na',
     balanceAfter: 'Saldo na',
     userWithId: 'Gebruiker #{{id}}',
     userWithId: 'Gebruiker #{{id}}',
+    deletedUser: 'Verwijderde gebruiker',
     partial: 'Gedeeltelijk',
     partial: 'Gedeeltelijk',
     editTransaction: 'Transactie bewerken',
     editTransaction: 'Transactie bewerken',
     selectCostCenter: 'Kostenplaats selecteren...',
     selectCostCenter: 'Kostenplaats selecteren...',

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'Centro de custo',
     costCenter: 'Centro de custo',
     balanceAfter: 'Saldo após',
     balanceAfter: 'Saldo após',
     userWithId: 'Usuário #{{id}}',
     userWithId: 'Usuário #{{id}}',
+    deletedUser: 'Usuário excluído',
     partial: 'Parcial',
     partial: 'Parcial',
     editTransaction: 'Editar transação',
     editTransaction: 'Editar transação',
     selectCostCenter: 'Selecionar centro de custo...',
     selectCostCenter: 'Selecionar centro de custo...',

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

@@ -199,6 +199,7 @@ export default {
     costCenter: "Центр затрат",
     costCenter: "Центр затрат",
     balanceAfter: "Баланс после операции",
     balanceAfter: "Баланс после операции",
     userWithId: "Пользователь №{{id}}",
     userWithId: "Пользователь №{{id}}",
+    deletedUser: "Удалённый пользователь",
     partial: "Частично",
     partial: "Частично",
     editTransaction: "Изменить операцию",
     editTransaction: "Изменить операцию",
     selectCostCenter: "Выберите центр затрат...",
     selectCostCenter: "Выберите центр затрат...",

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'Kostnadsställe',
     costCenter: 'Kostnadsställe',
     balanceAfter: 'Saldo efter',
     balanceAfter: 'Saldo efter',
     userWithId: 'Användare #{{id}}',
     userWithId: 'Användare #{{id}}',
+    deletedUser: 'Borttagen användare',
     partial: 'Delvis',
     partial: 'Delvis',
     editTransaction: 'Redigera transaktion',
     editTransaction: 'Redigera transaktion',
     selectCostCenter: 'Välj kostnadsställe...',
     selectCostCenter: 'Välj kostnadsställe...',

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'Masraf merkezi',
     costCenter: 'Masraf merkezi',
     balanceAfter: 'İşlem sonrası bakiye',
     balanceAfter: 'İşlem sonrası bakiye',
     userWithId: 'Kullanıcı #{{id}}',
     userWithId: 'Kullanıcı #{{id}}',
+    deletedUser: 'Silinmiş kullanıcı',
     partial: 'Kısmi',
     partial: 'Kısmi',
     editTransaction: 'İşlemi düzenle',
     editTransaction: 'İşlemi düzenle',
     selectCostCenter: 'Masraf merkezi seç...',
     selectCostCenter: 'Masraf merkezi seç...',

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

@@ -203,6 +203,7 @@ export default {
     costCenter: "Центр витрат",
     costCenter: "Центр витрат",
     balanceAfter: "Баланс після операції",
     balanceAfter: "Баланс після операції",
     userWithId: "Користувач №{{id}}",
     userWithId: "Користувач №{{id}}",
+    deletedUser: "Видалений користувач",
     partial: "Частково",
     partial: "Частково",
     editTransaction: "Редагувати операцію",
     editTransaction: "Редагувати операцію",
     selectCostCenter: "Виберіть центр витрат...",
     selectCostCenter: "Виберіть центр витрат...",

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

@@ -203,6 +203,7 @@ export default {
     costCenter: '成本中心',
     costCenter: '成本中心',
     balanceAfter: '调整后余额',
     balanceAfter: '调整后余额',
     userWithId: '用户 #{{id}}',
     userWithId: '用户 #{{id}}',
+    deletedUser: '已删除的用户',
     partial: '部分',
     partial: '部分',
     editTransaction: '编辑交易',
     editTransaction: '编辑交易',
     selectCostCenter: '选择成本中心...',
     selectCostCenter: '选择成本中心...',

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

@@ -203,6 +203,7 @@ export default {
     costCenter: '成本中心',
     costCenter: '成本中心',
     balanceAfter: '調整後餘額',
     balanceAfter: '調整後餘額',
     userWithId: '使用者 #{{id}}',
     userWithId: '使用者 #{{id}}',
+    deletedUser: '已刪除的使用者',
     partial: '部分',
     partial: '部分',
     editTransaction: '編輯交易',
     editTransaction: '編輯交易',
     selectCostCenter: '選擇成本中心...',
     selectCostCenter: '選擇成本中心...',

+ 6 - 3
frontend/src/pages/FinancePage.tsx

@@ -648,7 +648,8 @@ export function FinancePage() {
   };
   };
 
 
   const getPrivateOwnerLabel = (ownerUserId: number | null): string => {
   const getPrivateOwnerLabel = (ownerUserId: number | null): string => {
-    if (ownerUserId == null) return t('finance.personal', 'Personal');
+    // Private centres are always created with an owner; none means they were deleted.
+    if (ownerUserId == null) return t('finance.deletedUser', 'Deleted user');
     const ownerName = usersById.get(ownerUserId);
     const ownerName = usersById.get(ownerUserId);
     if (ownerName) return ownerName;
     if (ownerName) return ownerName;
     if (user?.id === ownerUserId && user.username) return user.username;
     if (user?.id === ownerUserId && user.username) return user.username;
@@ -1205,7 +1206,9 @@ export function FinancePage() {
                       const txCostCenter = tx.cost_center_id == null
                       const txCostCenter = tx.cost_center_id == null
                         ? null
                         ? null
                         : (costCenters || []).find((center) => center.id === tx.cost_center_id);
                         : (costCenters || []).find((center) => center.id === tx.cost_center_id);
-                      const txUser = usersById.get(tx.user_id);
+                      const txUser = tx.user_id == null
+                        ? t('finance.deletedUser', 'Deleted user')
+                        : usersById.get(tx.user_id) || t('finance.userWithId', 'User #{{id}}', { id: tx.user_id });
                       const parsed = tx.transaction_type === 'print_charge'
                       const parsed = tx.transaction_type === 'print_charge'
                         ? parsePrintChargeDescription((tx as { description: string | null }).description ?? null)
                         ? parsePrintChargeDescription((tx as { description: string | null }).description ?? null)
                         : null;
                         : null;
@@ -1215,7 +1218,7 @@ export function FinancePage() {
                         <tr key={tx.id} className="border-b border-bambu-dark-tertiary/60 text-white">
                         <tr key={tx.id} className="border-b border-bambu-dark-tertiary/60 text-white">
                             <td className={tableCellClass}>{formatTimestamp(tx.created_at, i18n.language)}</td>
                             <td className={tableCellClass}>{formatTimestamp(tx.created_at, i18n.language)}</td>
                             {showCostCenterAccountColumn && (
                             {showCostCenterAccountColumn && (
-                              <td className={tableCellClass}>{txUser || t('finance.userWithId', 'User #{{id}}', { id: tx.user_id })}</td>
+                              <td className={tableCellClass}>{txUser}</td>
                             )}
                             )}
                             <td className={tableCellClass}>{txCostCenter?.name || '-'}</td>
                             <td className={tableCellClass}>{txCostCenter?.name || '-'}</td>
                             <td className={tableCellClass}>
                             <td className={tableCellClass}>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AdditionalSection-CHyQBftl.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AmsUnitCard-Bxe48BU4.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ArchivesPage-D8uzKf8A.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/AssignToAmsModal-Cu3DesKJ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CamWallPage-BATXeHmc.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraDiagnoseModal-B4hPi6Hk.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraPage-lhxd4x9F.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/CameraWall-C9zsyvLd.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ColumnConfigModal-CPb3e8Mw.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConfigureAmsSlotModal-Dt3ofGaQ.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ConnectAuthorizePage-CdZErbrU.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ContextMenu-D0hBijF-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ExternalLinkPage-ClMvS7Y0.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/FileManagerPage-Buy0dwk-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FileUploadModal-D_RzIUV8.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FinancePage-Bw2jziNc.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/FinancePage-C7L1sCfN.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GCodeViewerPage-hW8Hhodb.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/GroupEditPage-DduIUx76.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-1dojQOP7.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/InventoryPage-BQXiEr0e.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LibraryTrashPage-BYQtsUh-.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/LoginPage-BuDfqO3p.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/MaintenancePage-_5J4yChf.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ModelSourcesPage-C_joM5GI.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/NotificationsPage-DGmpIw5G.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/PdfPreviewModal-BIba_lt4.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PhotoGalleryModal-DZCSklaS.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrinterLocationsPage-D2-jUrvu.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/PrintersPage-mI6BjZjC.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProfilesPage-NiAuzTyk.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectDetailPage-DxC6JNvf.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ProjectsPage-D9w5jvkV.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/QueuePage-uth6Bxn2.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/RichTextEditor-BUzpt7mm.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SettingsPage-DP1duQdC.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SetupPage-ChttfEsS.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SliceModal-BZ-lhiqA.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyAmsPage-x6ogi_Wk.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-DQtz18l3.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyDashboard-C71vG_Jt.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-D-R8C7Ax.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BVP0CF3J.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-IhXqsA2u.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpreadsheetPreviewModal-B4ezFUyc.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StatsPage-Be1dH9yj.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/StreamOverlayPage--BndpAsl.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SystemInfoPage-ViuTbhSS.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/de-BBbbJU4T.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/de-BfSmrO1D.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/es-Bz63egNE.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/es-Dn92fmS9.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/fr-CmF999mK.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/fr-sItnTtep.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-C1KZGB01.js


+ 1 - 1
static/assets/inventorySearch-DY7qW1VE.js → static/assets/inventorySearch-DnBIB0ZZ.js

@@ -1 +1 @@
-import{ft as e}from"./index-Bxt0-gCC.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-C1KZGB01.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/it-BwpvLhyg.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/it-DEhaofNm.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ja-B-VXWExv.js


Некоторые файлы не были показаны из-за большого количества измененных файлов