Bläddra i källkod

Security hardening (maziggy/bambuddy-security #12)

maziggy 1 vecka sedan
förälder
incheckning
c39e678204
100 ändrade filer med 1224 tillägg och 28 borttagningar
  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."""
     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,
     RequestActor,
     RequestPrinterScope,
+    require_actor_still_exists,
     require_media_token_ownership,
     require_ownership_permission,
     require_permission_if_auth_enabled,
@@ -2633,6 +2634,9 @@ async def extract_zip_file(
     folders_created = 0
     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
     zip_folder_id = folder_id
     logger.info(
@@ -2685,6 +2689,9 @@ async def extract_zip_file(
             ]
 
             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:
                     # Determine target folder (use zip_folder_id as base if create_folder_from_zip was used)
                     target_folder_id = zip_folder_id
@@ -2874,6 +2881,8 @@ async def extract_zip_file(
 
     except zipfile.BadZipFile:
         raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
+    except HTTPException:
+        raise
     except Exception as e:
         logger.error("ZIP extraction failed: %s", e, exc_info=True)
         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,
     # so ThreeMFParser picks it up here like any other 3MF's thumbnail. Loading
     # 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(
         db,
         file_bytes=content,
@@ -4896,6 +4906,10 @@ async def slice_and_persist(
     if 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(
         folder_id=folder_id,
         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
 
+    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(
         printer_id=new_printer_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,
     RequestActor,
     RequirePermissionIfAuthEnabled,
+    require_actor_still_exists,
     require_auth_if_enabled,
     require_permission_if_auth_enabled,
     security,
@@ -426,6 +427,8 @@ async def import_instance(
     # there as on the manifest-supplied name.
     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
     # resolved inside the provider.
     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.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.permissions import Permission
 from backend.app.models.library import LibraryFile
@@ -344,6 +344,8 @@ async def import_file(
 
     filename = _library_filename(file["filename"], file_id)
     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)
     library_file, was_existing = await save_3mf_bytes_to_library(
         db,

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

@@ -40,6 +40,7 @@ from backend.app.core.auth import (
     RequestActor,
     RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
+    is_auth_enabled,
 )
 from backend.app.core.config import settings as app_settings
 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:
     """Broadcast a ``pipeline_run_updated`` event with the full materialised
     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:
+        # 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)
         await ws_manager.broadcast_to_user(
             run.created_by,
@@ -593,11 +598,23 @@ def _make_orchestration_callable(
                     printer_id=printer_id,
                     target_model=target_model,
                     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",
                     confirm_outcome=confirm_outcome,
                     # 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)
                 await session.flush()

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

@@ -54,6 +54,8 @@ from backend.app.services.email_service import (
     send_email,
 )
 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
 
 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))
 
+    # 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.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()
 
     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)
 
 
+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(
     token: str | None = 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
     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.models.library import LibraryFile
     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
 
     if not is_sqlite():
@@ -369,6 +371,20 @@ async def _repair_sqlite_orphans(conn) -> None:
         await release_queue_references(conn, list(vanished))
     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():
     # 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 (
                 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,
                 transaction_type VARCHAR(40) 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 (
                 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,
                 transaction_type VARCHAR(40) 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_add_missing_set_null_fks(conn)
+    await _migrate_wallet_transactions_keep_deleted_users(conn)
     await _migrate_pg_column_types(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})
 
 
+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
 # PostgreSQL database older than the column has none: (table, column, parent).
 # 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,
             )
             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)
                 user_personal_balances[user_id] = new_balance
             else:
                 new_balance = round(cc_running_balances.get(cost_center_id, 0.0) + amount_value, 2)
                 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.get(user_id, 0.0) + amount_value,
                         2,

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

@@ -78,6 +78,36 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("print_batches", "project_id"),
         ("print_queue", "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))
 
 
+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:
     """Repair APPLIED references to rows that no longer exist (SQLite only).
 
@@ -229,6 +265,12 @@ async def sweep_orphans(conn: Any) -> None:
         rules = [
             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):
             changed = 0
             for fk in rules:

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

@@ -109,6 +109,21 @@ class ConnectionManager:
                 if conn in self.active_connections:
                     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):
         """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
 
 
+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:
     """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
 
 
+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(
     printer_id: int,
     filename: str,
@@ -8029,7 +8049,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 if archive and billing_run_id is None:
                     billing_run_id = getattr(archive, "billing_run_id", None)
                 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()
 
                 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
                 # overwrite an existing attribution — the original uploader
                 # 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:
                     archive.created_by_id = _print_user_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)
-    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(
         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)
 
-    user: Mapped[User] = relationship(foreign_keys=[user_id])
+    user: Mapped[User | None] = relationship(foreign_keys=[user_id])
     cost_center: Mapped[CostCenter | None] = relationship()
     created_by: Mapped[User | None] = relationship(foreign_keys=[created_by_user_id])
     print_archive: Mapped[PrintArchive | None] = relationship()

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

@@ -15,7 +15,7 @@ class WalletBalanceResponse(BaseModel):
 
 class WalletTransactionResponse(BaseModel):
     id: int
-    user_id: int
+    user_id: int | None  # None once the user is deleted
     cost_center_id: int | None = None
     transaction_type: Literal["print_charge", "deposit", "withdraw", "manual_adjustment"]
     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(
-    user_id: int,
+    user_id: int | None,
     cost_center_id: int | None,
     *,
     is_private: bool = False,
@@ -33,6 +33,8 @@ def transaction_affects_personal_balance(
 ) -> bool:
     """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)
 
 

+ 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)
         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:
         """Hand the queue item's owner to printer_manager so the
         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
         behaviour rather than crashing the dispatch).
         """
-        if not item.created_by_id:
-            return
         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:
             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
         # readable; the status writes below (upload-failure path and the
         # 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()
 
         # 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
         # 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", "")
         try:
             total_bytes = file_path.stat().st_size
@@ -8674,6 +8691,10 @@ class PrintScheduler:
         if injected_path and injected_path.exists():
             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:
             # This used to be one string for every upload failure, telling
             # everyone to check the SD card. The client knows which of seven
@@ -8739,7 +8760,9 @@ class PrintScheduler:
                 remote_filename,
                 archive.id,
                 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,
                 # The plate actually dispatched, not the queue item's raw
                 # 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)."""
         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:
         """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.

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

@@ -126,6 +126,23 @@ class SliceDispatchService:
     def get(self, job_id: int) -> SliceJob | None:
         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:
         """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 "SD card" not in message, 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 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 {
   id: number;
-  user_id: number;
+  user_id: number | null; // null once the user is deleted
   cost_center_id: number | null;
   transaction_type: WalletTransactionType;
   amount: number;

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

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

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

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

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

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

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

@@ -203,6 +203,7 @@ export default {
     costCenter: 'Centre de coûts',
     balanceAfter: 'Solde après',
     userWithId: 'Utilisateur #{{id}}',
+    deletedUser: 'Utilisateur supprimé',
     partial: 'Partiel',
     editTransaction: 'Modifier la transaction',
     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',
     balanceAfter: 'Saldo successivo',
     userWithId: 'Utente #{{id}}',
+    deletedUser: 'Utente eliminato',
     partial: 'Parziale',
     editTransaction: 'Modifica transazione',
     selectCostCenter: 'Seleziona centro di costo...',

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -648,7 +648,8 @@ export function FinancePage() {
   };
 
   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);
     if (ownerName) return ownerName;
     if (user?.id === ownerUserId && user.username) return user.username;
@@ -1205,7 +1206,9 @@ export function FinancePage() {
                       const txCostCenter = tx.cost_center_id == null
                         ? null
                         : (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'
                         ? parsePrintChargeDescription((tx as { description: string | null }).description ?? null)
                         : null;
@@ -1215,7 +1218,7 @@ export function FinancePage() {
                         <tr key={tx.id} className="border-b border-bambu-dark-tertiary/60 text-white">
                             <td className={tableCellClass}>{formatTimestamp(tx.created_at, i18n.language)}</td>
                             {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}>

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/AdditionalSection-CHyQBftl.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/AmsUnitCard-Bxe48BU4.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ArchivesPage-D8uzKf8A.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/AssignToAmsModal-Cu3DesKJ.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/CamWallPage-BATXeHmc.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/CameraDiagnoseModal-B4hPi6Hk.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/CameraPage-lhxd4x9F.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/CameraWall-C9zsyvLd.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ColumnConfigModal-CPb3e8Mw.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ConfigureAmsSlotModal-Dt3ofGaQ.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ConnectAuthorizePage-CdZErbrU.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ContextMenu-D0hBijF-.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ExternalLinkPage-ClMvS7Y0.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 1
static/assets/FileManagerPage-Buy0dwk-.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/FileUploadModal-D_RzIUV8.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/FinancePage-Bw2jziNc.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/FinancePage-C7L1sCfN.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/GCodeViewerPage-hW8Hhodb.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/GroupEditPage-DduIUx76.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ImagePreviewModal-1dojQOP7.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/InventoryPage-BQXiEr0e.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/LibraryTrashPage-BYQtsUh-.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/LoginPage-BuDfqO3p.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/MaintenancePage-_5J4yChf.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ModelSourcesPage-C_joM5GI.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/NotificationsPage-DGmpIw5G.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 1
static/assets/PdfPreviewModal-BIba_lt4.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/PhotoGalleryModal-DZCSklaS.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/PrinterLocationsPage-D2-jUrvu.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/PrintersPage-mI6BjZjC.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ProfilesPage-NiAuzTyk.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ProjectDetailPage-DxC6JNvf.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ProjectsPage-D9w5jvkV.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/QueuePage-uth6Bxn2.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/RichTextEditor-BUzpt7mm.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/SettingsPage-DP1duQdC.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/SetupPage-ChttfEsS.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/SliceModal-BZ-lhiqA.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/SpoolBuddyAmsPage-x6ogi_Wk.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-DQtz18l3.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/SpoolBuddyDashboard-C71vG_Jt.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-D-R8C7Ax.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BVP0CF3J.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-IhXqsA2u.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/SpreadsheetPreviewModal-B4ezFUyc.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/StatsPage-Be1dH9yj.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/StreamOverlayPage--BndpAsl.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/SystemInfoPage-ViuTbhSS.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/de-BBbbJU4T.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/de-BfSmrO1D.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/es-Bz63egNE.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/es-Dn92fmS9.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/fr-CmF999mK.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/fr-sItnTtep.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 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};

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/it-BwpvLhyg.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/it-DEhaofNm.js


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/ja-B-VXWExv.js


Vissa filer visades inte eftersom för många filer har ändrats