Sfoglia il codice sorgente

Personal access codes show who sent a file to a VP (#1430)

- Each user has a personal 8-digit access code (sidebar printer icon:
  show, copy, renew); entered in the slicer instead of the VP's code,
  uploads become that user's
- Checked at every sign-in: auth on, account active, library:upload
  (queue:create in queue mode), the target printer in the user's scope
  and printers:read on it; not for proxy mode
- Owner on the File Manager file, the queue jobs (which wait for review
  when the user's must, #1620) and the pending upload; an approved file
  stays the sender's; reuse never takes another sender's copy
- Bridge commands from a personal-code MQTT session reach the printer
  only with printers:control, checked per command
- FTP sign-in rate limit like MQTT's; on both a good sign-in no longer
  resets the count, and attempts count before the lookup
- Review uploads wait in a folder of their own, so a same-named file
  can't replace them
- Code cached per user in the UI; VP card hints at personal codes;
  card clears its copy timer on unmount
- Typecheck baseline down by 2
maziggy 2 giorni fa
parent
commit
d1d0e2f980
100 ha cambiato i file con 1588 aggiunte e 89 eliminazioni
  1. 0 16
      .basedpyright/baseline.json
  2. 1 0
      CHANGELOG.md
  3. 21 16
      backend/app/api/routes/pending_uploads.py
  4. 42 2
      backend/app/api/routes/virtual_printers.py
  5. 13 0
      backend/app/core/database.py
  6. 1 0
      backend/app/core/sqlite_fk.py
  7. 3 0
      backend/app/models/pending_upload.py
  8. 8 0
      backend/app/models/user.py
  9. 98 14
      backend/app/services/virtual_printer/ftp_server.py
  10. 5 1
      backend/app/services/virtual_printer/library_store.py
  11. 94 11
      backend/app/services/virtual_printer/manager.py
  12. 49 7
      backend/app/services/virtual_printer/mqtt_server.py
  13. 127 0
      backend/app/services/virtual_printer/user_codes.py
  14. 32 0
      backend/tests/integration/test_pending_upload_archive_project.py
  15. 207 0
      backend/tests/integration/test_vp_user_codes.py
  16. 103 3
      backend/tests/unit/services/test_virtual_printer.py
  17. 10 8
      backend/tests/unit/test_vp_mqtt_server.py
  18. 241 0
      backend/tests/unit/test_vp_personal_access_codes.py
  19. 64 0
      frontend/src/__tests__/components/SlicerAccessCodeModal.test.tsx
  20. 4 0
      frontend/src/api/client.ts
  21. 22 3
      frontend/src/components/Layout.tsx
  22. 155 0
      frontend/src/components/SlicerAccessCodeModal.tsx
  23. 11 2
      frontend/src/components/VirtualPrinterCard.tsx
  24. 17 0
      frontend/src/i18n/locales/de.ts
  25. 17 0
      frontend/src/i18n/locales/en.ts
  26. 17 0
      frontend/src/i18n/locales/es.ts
  27. 17 0
      frontend/src/i18n/locales/fr.ts
  28. 17 0
      frontend/src/i18n/locales/it.ts
  29. 17 0
      frontend/src/i18n/locales/ja.ts
  30. 17 0
      frontend/src/i18n/locales/ko.ts
  31. 17 0
      frontend/src/i18n/locales/nl.ts
  32. 17 0
      frontend/src/i18n/locales/pl.ts
  33. 17 0
      frontend/src/i18n/locales/pt-BR.ts
  34. 17 0
      frontend/src/i18n/locales/ru.ts
  35. 17 0
      frontend/src/i18n/locales/sv.ts
  36. 17 0
      frontend/src/i18n/locales/tr.ts
  37. 17 0
      frontend/src/i18n/locales/uk.ts
  38. 17 0
      frontend/src/i18n/locales/zh-CN.ts
  39. 17 0
      frontend/src/i18n/locales/zh-TW.ts
  40. 0 0
      static/assets/AdditionalSection-BxtxolDb.js
  41. 0 0
      static/assets/AmsUnitCard-Cvx3cGpb.js
  42. 0 0
      static/assets/ArchivesPage-CsPyefGD.js
  43. 0 0
      static/assets/AreaChart-DVSVDmD-.js
  44. 0 0
      static/assets/AssignToAmsModal-BnUo1K1X.js
  45. 0 0
      static/assets/AuthContext-D0GViS03.js
  46. 1 1
      static/assets/Button-D7wJJlop.js
  47. 0 0
      static/assets/CamWallPage-eOqM3xKB.js
  48. 0 0
      static/assets/CameraDiagnoseModal-DEZpCsOP.js
  49. 0 0
      static/assets/CameraPage-DCuC5iQJ.js
  50. 1 1
      static/assets/Card-CsobaR0C.js
  51. 0 0
      static/assets/CartesianChart-womSdg0g.js
  52. 0 0
      static/assets/ColumnConfigModal-D-J_stEa.js
  53. 0 0
      static/assets/ConfigureAmsSlotModal-QOLMRXQi.js
  54. 0 0
      static/assets/ConfirmModal-Bw8npIQH.js
  55. 0 0
      static/assets/ConfirmOutcomeDialog-C2spD3Yf.js
  56. 0 0
      static/assets/ConnectAuthorizePage-DyF6PZOZ.js
  57. 0 0
      static/assets/ConnectionDiagnostic-CKKaaGUZ.js
  58. 0 0
      static/assets/ContextMenu-DaSJUovz.js
  59. 0 0
      static/assets/CopyButton-CjReDRoV.js
  60. 0 0
      static/assets/EmbeddedCameraViewer-BHNMtfa3.js
  61. 0 0
      static/assets/ErrorBarContext-B5E3FKiw.js
  62. 0 0
      static/assets/ExternalLinkPage-r2imim06.js
  63. 1 1
      static/assets/FilamentSwatch-DPXw7nIu.js
  64. 0 1
      static/assets/FileManagerPage-DpFs7VbB.js
  65. 0 0
      static/assets/FileUploadModal-DhY7KyEZ.js
  66. 0 0
      static/assets/FinancePage-DiTM3jJy.js
  67. 0 0
      static/assets/GCodeViewerPage-CehpejFb.js
  68. 0 0
      static/assets/GcodeToolpathViewer-DiqZoBqC.js
  69. 0 0
      static/assets/GroupEditPage-BtND0DPd.js
  70. 0 0
      static/assets/IconPicker-MdTV8fFI.js
  71. 0 0
      static/assets/ImagePreviewModal-D-KzaynP.js
  72. 0 0
      static/assets/InventoryPage-BajD8xgK.js
  73. 0 0
      static/assets/Layout-CeU3kIEZ.js
  74. 0 0
      static/assets/Layout-rse8GjU_.js
  75. 0 0
      static/assets/LibraryTrashPage-R2dJSOWa.js
  76. 0 0
      static/assets/LoginPage-Cs4SmQMX.js
  77. 0 0
      static/assets/LoginPage-GFM57w-5.js
  78. 0 0
      static/assets/MaintenancePage-BjN5dGRZ.js
  79. 0 0
      static/assets/MergeDuplicatesModal-klI9EFhB.js
  80. 0 0
      static/assets/ModelSourcesPage-DSbbbP-S.js
  81. 0 0
      static/assets/ModelSourcesPage-VTmJQkIi.js
  82. 0 0
      static/assets/NotificationsPage-tcNXnvtX.js
  83. 1 1
      static/assets/NumberInput-BuS_39vU.js
  84. 1 1
      static/assets/OverlayFrame-taEGRB2T.js
  85. 0 0
      static/assets/PdfPreviewModal-BOQHikcq.js
  86. 0 0
      static/assets/PrintModal-D6wrVARn.js
  87. 0 0
      static/assets/PrinterLocationsPage-B4YVHKY0.js
  88. 0 0
      static/assets/PrintersPage-CKRMPHHu.js
  89. 0 0
      static/assets/ProfilesPage-BCT8RtsR.js
  90. 0 0
      static/assets/ProjectDetailPage-t1Aqw9pP.js
  91. 0 0
      static/assets/ProjectsPage-BahItkDh.js
  92. 0 0
      static/assets/QueryClientProvider-CPZTuCm3.js
  93. 0 0
      static/assets/QueuePage-CINipqNY.js
  94. 0 0
      static/assets/ReferenceLine-DvwXXtsl.js
  95. 0 0
      static/assets/RichTextEditor-CFxH8FDS.js
  96. 0 0
      static/assets/SettingsPage-B4Pmgkey.js
  97. 0 0
      static/assets/SettingsPage-CKLH6JLh.js
  98. 0 0
      static/assets/SetupPage-C6c_Rp8M.js
  99. 0 0
      static/assets/SkipObjectsModal-Bj4AFJ3J.js
  100. 0 0
      static/assets/SliceModal-Bs6icPhX.js

+ 0 - 16
.basedpyright/baseline.json

@@ -4391,14 +4391,6 @@
                     "lineCount": 1
                     "lineCount": 1
                 }
                 }
             },
             },
-            {
-                "code": "reportGeneralTypeIssues",
-                "range": {
-                    "startColumn": 26,
-                    "endColumn": 32,
-                    "lineCount": 1
-                }
-            },
             {
             {
                 "code": "reportArgumentType",
                 "code": "reportArgumentType",
                 "range": {
                 "range": {
@@ -4441,14 +4433,6 @@
                     "lineCount": 1
                     "lineCount": 1
                 }
                 }
             },
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 29,
-                    "endColumn": 50,
-                    "lineCount": 1
-                }
-            },
             {
             {
                 "code": "reportOptionalCall",
                 "code": "reportOptionalCall",
                 "range": {
                 "range": {

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.1b1] - Unreleased
 ## [1.3.1b1] - Unreleased
 
 
 ### Added
 ### Added
+- **Personal access codes show who sent a file to a virtual printer (#1430)** — A slicer signs in to a printer with one access code, so a virtual printer never knew who sent a file: in a school or makerspace, queue jobs and files had no owner. With auth on, every user now has a personal access code, under the printer icon next to Change password in the sidebar (copy it, or make a new one, which retires the old). Entered in Bambu Studio or OrcaSlicer instead of the virtual printer's own code, it works on any virtual printer in File Manager, Review or Queue mode, and what that slicer sends is the user's: the File Manager file, the queue jobs (which wait for a reviewer when the user's jobs must) and, in Review mode, the pending upload, whose file stays the sender's when someone else approves it. Each sign-in checks the user's account is active, that they may upload to the File Manager (plus queue jobs in Queue mode) and, for a virtual printer with a target printer, that the printer is one of theirs and they may see it, so a change of permissions or a new code takes effect at once. Sending files is all a personal code allows: the slicer's commands to the target printer (stop, pause, G-code, AMS) go through only for users with the printer control permission, checked at every command. An upload waiting for review keeps its own copy, so a later file of the same name can't replace it. The virtual printer's own code keeps working as before, without an owner. Not for Proxy mode, and with a personal code the slicer's own camera view through a virtual printer with a target printer does not work (Bambuddy's camera does). The virtual printer's FTP sign-in now refuses an address for a minute after five wrong codes, as its MQTT sign-in already did, and on both a good sign-in no longer resets the count.
 - **A script adds the virtual printer certificate to the slicers** — Bambu Studio and OrcaSlicer only connect to a virtual printer whose certificate is in their printer.cer file, and every slicer update or reinstall puts the original file back, so the certificate had to be added by hand again. The **Slicer certificate** card under Settings > Virtual Printer now downloads a script for **Windows** or **Linux / macOS** with this Bambuddy's certificate already in it. It finds Bambu Studio and OrcaSlicer, adds the certificate, and keeps the original file next to it. It changes nothing where the certificate is already there, and it removes an older certificate of the same Bambuddy, which would otherwise stop the slicer from connecting. On Windows it is a double-click: it asks for administrator rights, and it installs a task that adds the certificate again at every start, at every sign-in and once an hour, so a slicer update no longer breaks the connection (`/uninstall` removes the task). On Linux and macOS it runs with bash, and is run again after a slicer update. Wiki updated.
 - **A script adds the virtual printer certificate to the slicers** — Bambu Studio and OrcaSlicer only connect to a virtual printer whose certificate is in their printer.cer file, and every slicer update or reinstall puts the original file back, so the certificate had to be added by hand again. The **Slicer certificate** card under Settings > Virtual Printer now downloads a script for **Windows** or **Linux / macOS** with this Bambuddy's certificate already in it. It finds Bambu Studio and OrcaSlicer, adds the certificate, and keeps the original file next to it. It changes nothing where the certificate is already there, and it removes an older certificate of the same Bambuddy, which would otherwise stop the slicer from connecting. On Windows it is a double-click: it asks for administrator rights, and it installs a task that adds the certificate again at every start, at every sign-in and once an hour, so a slicer update no longer breaks the connection (`/uninstall` removes the task). On Linux and macOS it runs with bash, and is run again after a slicer update. Wiki updated.
 - **WLED lights show the printer's status (#1528, requested and contributed by @d3nn3s08 in #3124)** — A WLED controller can now show what each printer is doing. Under **Settings > WLED**, pick a printer, enter its WLED address and assign one of the presets saved in WLED to each state: Idle, Preparing, Printing, Paused, Finished, Failed / Error, Awaiting plate clear, Filament problem, HMS error and Offline. Colours, effects and segments stay in WLED; Bambuddy only switches presets. A state without a preset falls back to the next one that has one (a filament runout without its own preset shows Paused). **Awaiting plate clear** applies only while the queue's **Require plate clear** setting holds the printer, and **Filament problem** only when the printer paused because the filament ran out. An optional timeout switches Finished back to Idle. The page can test the connection, load the presets from WLED and try each preset on the strip. If WLED is unreachable, Bambuddy retries every 30 seconds and logs the outage once. Several printers can share one controller when each preset is saved with "Checked segments only". The tab needs the `printers:update` permission.
 - **WLED lights show the printer's status (#1528, requested and contributed by @d3nn3s08 in #3124)** — A WLED controller can now show what each printer is doing. Under **Settings > WLED**, pick a printer, enter its WLED address and assign one of the presets saved in WLED to each state: Idle, Preparing, Printing, Paused, Finished, Failed / Error, Awaiting plate clear, Filament problem, HMS error and Offline. Colours, effects and segments stay in WLED; Bambuddy only switches presets. A state without a preset falls back to the next one that has one (a filament runout without its own preset shows Paused). **Awaiting plate clear** applies only while the queue's **Require plate clear** setting holds the printer, and **Filament problem** only when the printer paused because the filament ran out. An optional timeout switches Finished back to Idle. The page can test the connection, load the presets from WLED and try each preset on the strip. If WLED is unreachable, Bambuddy retries every 30 seconds and logs the outage once. Several printers can share one controller when each preset is saved with "Checked segments only". The tab needs the `printers:update` permission.
 - **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
 - **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.

+ 21 - 16
backend/app/api/routes/pending_uploads.py

@@ -132,6 +132,22 @@ async def get_pending_count(
 # to prevent FastAPI from matching /archive-all as /{upload_id}
 # to prevent FastAPI from matching /archive-all as /{upload_id}
 
 
 
 
+def _remove_waiting_file(file_path: str) -> None:
+    """Delete an upload's waiting file, and the folder of its own it waited in.
+
+    Best effort: the record is updated either way. Uploads wait in
+    ``uploads/<vp>/pending/<id>/<name>`` (older ones directly in the upload
+    folder), so a later upload of the same name can't replace them.
+    """
+    path = Path(file_path)
+    try:
+        path.unlink(missing_ok=True)
+        if path.parent.parent.name == "pending":
+            path.parent.rmdir()
+    except OSError:
+        pass
+
+
 async def _store_pending(db: AsyncSession, pending: PendingUpload, request: "ArchiveRequest | None"):
 async def _store_pending(db: AsyncSession, pending: PendingUpload, request: "ArchiveRequest | None"):
     """Store an approved upload in its virtual printer's File Manager folder.
     """Store an approved upload in its virtual printer's File Manager folder.
 
 
@@ -150,6 +166,8 @@ async def _store_pending(db: AsyncSession, pending: PendingUpload, request: "Arc
         source_path=Path(pending.file_path),
         source_path=Path(pending.file_path),
         filename=pending.filename,
         filename=pending.filename,
         folder_id=folder_id,
         folder_id=folder_id,
+        # The sender's, not the approver's (#1430).
+        owner_id=pending.uploaded_by_id,
         notes=request.notes if request else None,
         notes=request.notes if request else None,
         project_id=(request.project_id or None) if request else None,
         project_id=(request.project_id or None) if request else None,
         tags=request.tags if request else None,
         tags=request.tags if request else None,
@@ -161,10 +179,7 @@ async def _store_pending(db: AsyncSession, pending: PendingUpload, request: "Arc
         pending.notes = request.notes
         pending.notes = request.notes
         pending.project_id = request.project_id or None
         pending.project_id = request.project_id or None
     await db.commit()
     await db.commit()
-    try:
-        Path(pending.file_path).unlink()
-    except OSError:
-        pass  # Best-effort temp file cleanup; the File Manager has its copy
+    _remove_waiting_file(pending.file_path)  # the File Manager has its copy
     return library_file
     return library_file
 
 
 
 
@@ -217,12 +232,7 @@ async def discard_all_pending(
     discarded = 0
     discarded = 0
 
 
     for pending in pending_uploads:
     for pending in pending_uploads:
-        # Delete file from disk
-        try:
-            file_path = Path(pending.file_path)
-            file_path.unlink(missing_ok=True)
-        except OSError:
-            pass  # Best-effort file deletion; record is still marked discarded
+        _remove_waiting_file(pending.file_path)
 
 
         pending.status = "discarded"
         pending.status = "discarded"
         discarded += 1
         discarded += 1
@@ -307,12 +317,7 @@ async def discard_pending_upload(
     if not pending:
     if not pending:
         raise HTTPException(status_code=404, detail="Upload not found")
         raise HTTPException(status_code=404, detail="Upload not found")
 
 
-    # Delete file from disk
-    file_path = Path(pending.file_path)
-    try:
-        file_path.unlink(missing_ok=True)
-    except OSError:
-        pass  # Best-effort file deletion on discard
+    _remove_waiting_file(pending.file_path)
 
 
     # Update status
     # Update status
     pending.status = "discarded"
     pending.status = "discarded"

+ 42 - 2
backend/app/api/routes/virtual_printers.py

@@ -2,13 +2,19 @@ import logging
 import os
 import os
 from pathlib import Path
 from pathlib import Path
 
 
-from fastapi import APIRouter, Depends
+from fastapi import APIRouter, Depends, HTTPException
 from fastapi.responses import JSONResponse
 from fastapi.responses import JSONResponse
 from pydantic import BaseModel
 from pydantic import BaseModel
 from sqlalchemy import select, update
 from sqlalchemy import select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import ApiKeyActor, RequestActor, RequestPrinterScope, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    ApiKeyActor,
+    RequestActor,
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    get_current_active_user,
+)
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.printer_scope import PrinterScope
@@ -388,6 +394,40 @@ async def get_ca_certificate_installer(
     return {"filename": installer.filename, "content": installer.content}
     return {"filename": installer.filename, "content": installer.content}
 
 
 
 
+@router.get("/my-access-code")
+async def get_my_access_code(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """The signed-in user's personal access code for virtual printers (#1430).
+
+    Entered in their slicer instead of a virtual printer's own code, it marks
+    what they send as theirs. Made on first use. Only for a signed-in user (an
+    API key has no slicer); a virtual printer checks at every sign-in whether
+    the user may send to it.
+    """
+    from backend.app.services.virtual_printer.user_codes import code_for
+
+    user = await db.get(User, current_user.id)
+    if user is None:
+        raise HTTPException(status_code=401, detail="Not signed in")
+    return {"code": await code_for(db, user)}
+
+
+@router.post("/my-access-code")
+async def renew_my_access_code(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db, scope="function"),
+):
+    """Replace the signed-in user's personal access code; the old one stops working."""
+    from backend.app.services.virtual_printer.user_codes import code_for
+
+    user = await db.get(User, current_user.id)
+    if user is None:
+        raise HTTPException(status_code=401, detail="Not signed in")
+    return {"code": await code_for(db, user, renew=True)}
+
+
 @router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
 @router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
 async def diagnose_virtual_printer(
 async def diagnose_virtual_printer(
     vp_id: int,
     vp_id: int,

+ 13 - 0
backend/app/core/database.py

@@ -2880,6 +2880,19 @@ async def _run_migrations(conn):
     )
     )
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN slicer_ams_mapping TEXT")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN slicer_ams_mapping TEXT")
 
 
+    # Migration: personal virtual-printer access codes (#1430). The code a
+    # slicer signs in with names the user an upload belongs to; the pending
+    # upload keeps that user until it is approved.
+    await _safe_execute(conn, "ALTER TABLE users ADD COLUMN slicer_access_code VARCHAR(8)")
+    await _safe_execute(
+        conn,
+        "CREATE UNIQUE INDEX IF NOT EXISTS ix_users_slicer_access_code ON users (slicer_access_code)",
+    )
+    await _safe_execute(
+        conn,
+        "ALTER TABLE pending_uploads ADD COLUMN uploaded_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
+    )
+
     # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap
     # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap
     # slicer-pick preservation (#1780). Opaque JSON-string column carrying
     # slicer-pick preservation (#1780). Opaque JSON-string column carrying
     # BambuStudio's per-filament physical nozzle position IDs, forwarded
     # BambuStudio's per-filament physical nozzle position IDs, forwarded

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

@@ -126,6 +126,7 @@ APPLIED: frozenset[tuple[str, str]] = frozenset(
         ("library_files", "created_by_id"),
         ("library_files", "created_by_id"),
         ("library_folders", "created_by_id"),
         ("library_folders", "created_by_id"),
         ("long_lived_tokens", "user_id"),
         ("long_lived_tokens", "user_id"),
+        ("pending_uploads", "uploaded_by_id"),
         ("pipeline_runs", "created_by"),
         ("pipeline_runs", "created_by"),
         ("print_archives", "created_by_id"),
         ("print_archives", "created_by_id"),
         ("print_batches", "created_by_id"),
         ("print_batches", "created_by_id"),

+ 3 - 0
backend/app/models/pending_upload.py

@@ -34,6 +34,9 @@ class PendingUpload(Base):
     virtual_printer_id: Mapped[int | None] = mapped_column(
     virtual_printer_id: Mapped[int | None] = mapped_column(
         ForeignKey("virtual_printers.id", ondelete="SET NULL"), nullable=True
         ForeignKey("virtual_printers.id", ondelete="SET NULL"), nullable=True
     )
     )
+    # Who sent it, when the slicer signed in with a personal access code
+    # (#1430): the File Manager file it becomes on approval is theirs.
+    uploaded_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
 
 
     # Status: pending, archived, discarded
     # Status: pending, archived, discarded
     status: Mapped[str] = mapped_column(String(20), default="pending")
     status: Mapped[str] = mapped_column(String(20), default="pending")

+ 8 - 0
backend/app/models/user.py

@@ -45,6 +45,14 @@ class User(Base):
     # whoever is given the name next.
     # whoever is given the name next.
     password_changed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=utcnow_naive)
     password_changed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=utcnow_naive)
 
 
+    # Personal access code for a virtual printer (#1430): entered in the user's
+    # slicer instead of the virtual printer's own code, it tells the virtual
+    # printer whose upload it is. Kept readable (as printers' and virtual
+    # printers' codes are), since the user has to read it back to type it in.
+    # NULL until the user first opens it. A credential: never logged, never in
+    # an API response other than the user's own.
+    slicer_access_code: Mapped[str | None] = mapped_column(String(8), nullable=True, unique=True, index=True)
+
     # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
     # Per-user Bambu Cloud credentials (when auth is enabled, each user has their own)
     cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
     cloud_token: Mapped[str | None] = mapped_column(String(500), nullable=True, default=None)
     cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
     cloud_email: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)

+ 98 - 14
backend/app/services/virtual_printer/ftp_server.py

@@ -13,8 +13,9 @@ import logging
 import os
 import os
 import random
 import random
 import ssl
 import ssl
+import time
 import zipfile
 import zipfile
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
 from pathlib import Path
 from pathlib import Path
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -34,6 +35,50 @@ FTP_PORT = 990
 # with a legitimate file, raise here.
 # with a legitimate file, raise here.
 MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024  # 4 GiB
 MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024  # 4 GiB
 
 
+# Per-IP sign-in limit, as the MQTT server's: 5 wrong codes within 60 s and
+# that address is refused for the rest of the window. Personal access codes
+# (#1430) mean more codes that work, so more to guess at.
+AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
+AUTH_RATE_LIMIT_WINDOW_SECONDS = 60.0
+
+
+class AuthRateLimiter:
+    """Failed sign-ins per source IP within a sliding window."""
+
+    def __init__(
+        self,
+        max_attempts: int = AUTH_RATE_LIMIT_MAX_ATTEMPTS,
+        window_seconds: float = AUTH_RATE_LIMIT_WINDOW_SECONDS,
+    ):
+        self.max_attempts = max_attempts
+        self.window_seconds = window_seconds
+        self._failures: dict[str, list[float]] = {}
+
+    def is_limited(self, source_ip: str) -> bool:
+        cutoff = time.monotonic() - self.window_seconds
+        recent = [t for t in self._failures.get(source_ip, []) if t >= cutoff]
+        if recent:
+            self._failures[source_ip] = recent
+        else:
+            self._failures.pop(source_ip, None)
+        return len(recent) >= self.max_attempts
+
+    def record_failure(self, source_ip: str) -> None:
+        self._failures.setdefault(source_ip, []).append(time.monotonic())
+
+    def release(self, source_ip: str) -> None:
+        """Take back one attempt counted ahead of a sign-in that then succeeded.
+
+        Only that one: a good sign-in must not wipe the failures before it,
+        or a user with a code of their own could guess others' codes four at
+        a time between their own sign-ins.
+        """
+        attempts = self._failures.get(source_ip)
+        if attempts:
+            attempts.pop()
+            if not attempts:
+                self._failures.pop(source_ip, None)
+
 
 
 class FTPSession:
 class FTPSession:
     """Handles a single FTP client session."""
     """Handles a single FTP client session."""
@@ -45,11 +90,13 @@ class FTPSession:
         upload_dir: Path,
         upload_dir: Path,
         access_code: str,
         access_code: str,
         ssl_context: ssl.SSLContext,
         ssl_context: ssl.SSLContext,
-        on_file_received: Callable[[Path, str], None] | None,
+        on_file_received: Callable[..., object] | None,
         passive_port_range: tuple[int, int] = (50000, 50100),
         passive_port_range: tuple[int, int] = (50000, 50100),
         pasv_address: str = "",
         pasv_address: str = "",
         bind_address: str = "0.0.0.0",  # nosec B104
         bind_address: str = "0.0.0.0",  # nosec B104
         vp_name: str = "",
         vp_name: str = "",
+        authenticate_user: Callable[[str], Awaitable[int | None]] | None = None,
+        rate_limiter: AuthRateLimiter | None = None,
     ):
     ):
         self.reader = reader
         self.reader = reader
         self.writer = writer
         self.writer = writer
@@ -62,8 +109,13 @@ class FTPSession:
         self.bind_address = bind_address
         self.bind_address = bind_address
         self.vp_name = vp_name
         self.vp_name = vp_name
         self._log_prefix = f"[{vp_name}] " if vp_name else ""
         self._log_prefix = f"[{vp_name}] " if vp_name else ""
+        # A personal access code (#1430): returns the user's id, or None.
+        self.authenticate_user = authenticate_user
+        self.rate_limiter = rate_limiter or AuthRateLimiter()
 
 
         self.authenticated = False
         self.authenticated = False
+        # The user whose personal code signed in; None for the VP's own code.
+        self.user_id: int | None = None
         self.username: str | None = None
         self.username: str | None = None
         self.current_dir = upload_dir
         self.current_dir = upload_dir
         self.transfer_type = "A"  # ASCII by default
         self.transfer_type = "A"  # ASCII by default
@@ -171,19 +223,40 @@ class FTPSession:
 
 
     async def cmd_PASS(self, arg: str) -> None:
     async def cmd_PASS(self, arg: str) -> None:
         """Handle PASS command."""
         """Handle PASS command."""
-        if self.username and self.username.lower() == "bblp":
-            # ``hmac.compare_digest`` is constant-time — keeps the auth check
-            # from leaking the access code via response timing under network
-            # jitter. LAN-only threat is marginal; this is the standard fix.
-            if hmac.compare_digest(arg, self.access_code):
-                self.authenticated = True
-                await self.send(230, "Login successful")
+        if not (self.username and self.username.lower() == "bblp"):
+            await self.send(503, "Login with USER first")
+            return
+        if self.authenticated:
+            # One sign-in per session: no guessing on a session already in.
+            await self.send(503, "Already logged in")
+            return
+        if self.rate_limiter.is_limited(self.remote_ip):
+            await self.send(530, "Login incorrect")
+            logger.warning("%sFTP login from %s refused: too many failed attempts", self._log_prefix, self.remote_ip)
+            return
+        # Counted before the lookup, so sign-ins in parallel can't all pass
+        # the check above first; taken back below if this one succeeds.
+        self.rate_limiter.record_failure(self.remote_ip)
+        # ``hmac.compare_digest`` is constant-time — keeps the auth check
+        # from leaking the access code via response timing under network
+        # jitter. LAN-only threat is marginal; this is the standard fix.
+        user_id = None
+        ok = hmac.compare_digest(arg, self.access_code)
+        if not ok and self.authenticate_user is not None:
+            user_id = await self.authenticate_user(arg)
+            ok = user_id is not None
+        if ok:
+            self.authenticated = True
+            self.user_id = user_id
+            self.rate_limiter.release(self.remote_ip)
+            await self.send(230, "Login successful")
+            if user_id is None:
                 logger.info("%sFTP login from %s", self._log_prefix, self.remote_ip)
                 logger.info("%sFTP login from %s", self._log_prefix, self.remote_ip)
             else:
             else:
-                await self.send(530, "Login incorrect")
-                logger.warning("%sFTP failed login from %s (access code mismatch)", self._log_prefix, self.remote_ip)
+                logger.info("%sFTP login from %s as user %s", self._log_prefix, self.remote_ip, user_id)
         else:
         else:
-            await self.send(503, "Login with USER first")
+            await self.send(530, "Login incorrect")
+            logger.warning("%sFTP failed login from %s (access code mismatch)", self._log_prefix, self.remote_ip)
 
 
     async def cmd_SYST(self, arg: str) -> None:
     async def cmd_SYST(self, arg: str) -> None:
         """Handle SYST command."""
         """Handle SYST command."""
@@ -520,7 +593,12 @@ class FTPSession:
 
 
         if self.on_file_received:
         if self.on_file_received:
             try:
             try:
-                result = self.on_file_received(file_path, self.remote_ip)
+                # With the user only when a personal code signed in, so a
+                # callback that takes just (path, ip) keeps working.
+                if self.user_id is None:
+                    result = self.on_file_received(file_path, self.remote_ip)
+                else:
+                    result = self.on_file_received(file_path, self.remote_ip, user_id=self.user_id)
                 if asyncio.iscoroutine(result):
                 if asyncio.iscoroutine(result):
                     await result
                     await result
             except Exception as e:
             except Exception as e:
@@ -641,11 +719,12 @@ class VirtualPrinterFTPServer:
         cert_path: Path,
         cert_path: Path,
         key_path: Path,
         key_path: Path,
         port: int = FTP_PORT,
         port: int = FTP_PORT,
-        on_file_received: Callable[[Path, str], None] | None = None,
+        on_file_received: Callable[..., object] | None = None,
         bind_address: str = "0.0.0.0",  # nosec B104
         bind_address: str = "0.0.0.0",  # nosec B104
         vp_name: str = "",
         vp_name: str = "",
         passive_port_min: int = PASSIVE_PORT_BASE,
         passive_port_min: int = PASSIVE_PORT_BASE,
         passive_port_max: int = PASSIVE_PORT_BASE + PASSIVE_SLICE_SIZE - 1,
         passive_port_max: int = PASSIVE_PORT_BASE + PASSIVE_SLICE_SIZE - 1,
+        authenticate_user: Callable[[str], Awaitable[int | None]] | None = None,
     ):
     ):
         """Initialize the FTPS server.
         """Initialize the FTPS server.
 
 
@@ -674,6 +753,9 @@ class VirtualPrinterFTPServer:
         self.vp_name = vp_name
         self.vp_name = vp_name
         self.passive_port_min = passive_port_min
         self.passive_port_min = passive_port_min
         self.passive_port_max = passive_port_max
         self.passive_port_max = passive_port_max
+        # Personal access codes (#1430), and failed sign-ins per IP across sessions.
+        self.authenticate_user = authenticate_user
+        self._rate_limiter = AuthRateLimiter()
         self._server: asyncio.Server | None = None
         self._server: asyncio.Server | None = None
         self._running = False
         self._running = False
         # Set after the socket is bound and the server is accepting connections,
         # Set after the socket is bound and the server is accepting connections,
@@ -781,6 +863,8 @@ class VirtualPrinterFTPServer:
             pasv_address=self._pasv_address,
             pasv_address=self._pasv_address,
             bind_address=self.bind_address,
             bind_address=self.bind_address,
             vp_name=self.vp_name,
             vp_name=self.vp_name,
+            authenticate_user=self.authenticate_user,
+            rate_limiter=self._rate_limiter,
         )
         )
 
 
         # Track the session task so we can cancel it on stop
         # Track the session task so we can cancel it on stop

+ 5 - 1
backend/app/services/virtual_printer/library_store.py

@@ -121,7 +121,9 @@ async def store_upload(
     file already there (``reused``), so sending a plate twice does not leave
     file already there (``reused``), so sending a plate twice does not leave
     two copies; a queue gets a job for each send all the same. Only files a
     two copies; a queue gets a job for each send all the same. Only files a
     virtual printer stored count: a file someone put there by hand is never
     virtual printer stored count: a file someone put there by hand is never
-    taken over. Notes, project and tags are applied to the file either way.
+    taken over, and neither is another sender's (``owner_id``, the user whose
+    personal access code sent it, #1430). Notes, project and tags are applied
+    to the file either way.
 
 
     A file a queue job removes once it prints ("Remove from File Manager once
     A file a queue job removes once it prints ("Remove from File Manager once
     it prints") is never shared: such a job asks for a file of its own
     it prints") is never shared: such a job asks for a file of its own
@@ -150,6 +152,8 @@ async def store_upload(
                     LibraryFile.file_hash == file_hash,
                     LibraryFile.file_hash == file_hash,
                     LibraryFile.source_type == SOURCE_TYPE,
                     LibraryFile.source_type == SOURCE_TYPE,
                     ~removed_on_print,
                     ~removed_on_print,
+                    # Only the same sender's: someone else's copy stays theirs.
+                    LibraryFile.created_by_id.is_(None) if owner_id is None else LibraryFile.created_by_id == owner_id,
                 )
                 )
                 .order_by(LibraryFile.id)
                 .order_by(LibraryFile.id)
                 .limit(1)
                 .limit(1)

+ 94 - 11
backend/app/services/virtual_printer/manager.py

@@ -8,6 +8,7 @@ import asyncio
 import json
 import json
 import logging
 import logging
 import time
 import time
+import uuid
 from collections.abc import Callable
 from collections.abc import Callable
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
@@ -378,8 +379,49 @@ class VirtualPrinterInstance:
 
 
     # -- File handling callbacks --
     # -- File handling callbacks --
 
 
-    async def on_file_received(self, file_path: Path, source_ip: str) -> None:
-        """Handle file upload completion from FTP."""
+    async def authenticate_user_code(self, code: str) -> int | None:
+        """The user whose personal access code ``code`` is, if they may send here (#1430).
+
+        Asked by the FTP and MQTT servers when a sign-in is not the VP's own
+        code. Checked fresh at each sign-in; see ``user_codes.user_for_code``.
+        """
+        if not self._session_factory or self.is_proxy:
+            return None
+        from backend.app.services.virtual_printer.user_codes import user_for_code
+
+        try:
+            async with self._session_factory() as db:
+                user = await user_for_code(db, code, mode=self.mode, target_printer_id=self.target_printer_id)
+        except Exception as e:  # noqa: BLE001 -- a lookup error refuses the sign-in, never crashes it
+            logger.error("[VP %s] Personal access code check failed: %s", self.name, e)
+            return None
+        return user.id if user is not None else None
+
+    async def user_may_control_target(self, user_id: int) -> bool:
+        """Whether a personal-code user may send commands to the target printer now (#1430).
+
+        Asked for every command the bridge would pass to the real printer for
+        a connection that signed in with a personal code: sending files does
+        not include stopping prints or sending G-code. Fresh each time, so it
+        follows the user's account and permissions while the slicer stays on.
+        """
+        if not self._session_factory or self.target_printer_id is None:
+            return False
+        from backend.app.services.virtual_printer.user_codes import may_control_printer
+
+        try:
+            async with self._session_factory() as db:
+                return await may_control_printer(db, user_id, self.target_printer_id)
+        except Exception as e:  # noqa: BLE001 -- a lookup error refuses the command
+            logger.error("[VP %s] Printer control check failed: %s", self.name, e)
+            return False
+
+    async def on_file_received(self, file_path: Path, source_ip: str, user_id: int | None = None) -> None:
+        """Handle file upload completion from FTP.
+
+        ``user_id``: who sent it, when the slicer signed in with a personal
+        access code (#1430); the File Manager file and queue jobs are theirs.
+        """
         logger.info("[VP %s] Received file: %s from %s", self.name, file_path.name, source_ip)
         logger.info("[VP %s] Received file: %s from %s", self.name, file_path.name, source_ip)
 
 
         self._pending_files[file_path.name] = file_path
         self._pending_files[file_path.name] = file_path
@@ -390,11 +432,11 @@ class VirtualPrinterInstance:
         # `core/database.py` rewrites existing rows once at boot.
         # `core/database.py` rewrites existing rows once at boot.
         mode = normalize_vp_mode(self.mode)
         mode = normalize_vp_mode(self.mode)
         if mode == VP_MODE_LIBRARY:
         if mode == VP_MODE_LIBRARY:
-            await self._store_in_library(file_path, source_ip)
+            await self._store_in_library(file_path, source_ip, user_id=user_id)
         elif mode == VP_MODE_QUEUE:
         elif mode == VP_MODE_QUEUE:
-            await self._add_to_print_queue(file_path, source_ip)
+            await self._add_to_print_queue(file_path, source_ip, user_id=user_id)
         else:
         else:
-            await self._queue_file(file_path, source_ip)
+            await self._queue_file(file_path, source_ip, user_id=user_id)
 
 
         # Signal job completion to the slicer. Send-flow slicers don't watch the
         # Signal job completion to the slicer. Send-flow slicers don't watch the
         # post-upload state and would be happy with anything; the Print flow
         # post-upload state and would be happy with anything; the Print flow
@@ -767,7 +809,7 @@ class VirtualPrinterInstance:
         self._mqtt.set_gcode_state("FINISH", filename=filename, prepare_percent="100")
         self._mqtt.set_gcode_state("FINISH", filename=filename, prepare_percent="100")
         logger.debug("[VP %s] Re-set gcode_state=FINISH after project_file ack (%s)", self.name, filename)
         logger.debug("[VP %s] Re-set gcode_state=FINISH after project_file ack (%s)", self.name, filename)
 
 
-    async def _store_in_library(self, file_path: Path, source_ip: str) -> None:
+    async def _store_in_library(self, file_path: Path, source_ip: str, user_id: int | None = None) -> None:
         """Store the upload in the File Manager (library mode)."""
         """Store the upload in the File Manager (library mode)."""
         if not self._session_factory:
         if not self._session_factory:
             logger.error("Cannot store upload: no database session factory configured")
             logger.error("Cannot store upload: no database session factory configured")
@@ -783,7 +825,7 @@ class VirtualPrinterInstance:
             async with self._session_factory() as db:
             async with self._session_factory() as db:
                 folder_id, _ = await upload_settings(db, self.id)
                 folder_id, _ = await upload_settings(db, self.id)
                 library_file, reused = await store_upload(
                 library_file, reused = await store_upload(
-                    db, source_path=file_path, filename=file_path.name, folder_id=folder_id
+                    db, source_path=file_path, filename=file_path.name, folder_id=folder_id, owner_id=user_id
                 )
                 )
             logger.info(
             logger.info(
                 "[VP %s] %s File Manager file %s (%s) from %s",
                 "[VP %s] %s File Manager file %s (%s) from %s",
@@ -806,7 +848,7 @@ class VirtualPrinterInstance:
             except OSError:
             except OSError:
                 pass
                 pass
 
 
-    async def _queue_file(self, file_path: Path, source_ip: str) -> None:
+    async def _queue_file(self, file_path: Path, source_ip: str, user_id: int | None = None) -> None:
         """Queue file for user review."""
         """Queue file for user review."""
         if not self._session_factory:
         if not self._session_factory:
             logger.error("Cannot queue: no database session factory configured")
             logger.error("Cannot queue: no database session factory configured")
@@ -837,6 +879,24 @@ class VirtualPrinterInstance:
         except Exception as e:
         except Exception as e:
             logger.debug("[VP %s] Metadata title peek failed for %s: %s", self.name, file_path.name, e)
             logger.debug("[VP %s] Metadata title peek failed for %s: %s", self.name, file_path.name, e)
 
 
+        # The upload waits for its review in a folder of its own: the slicer
+        # sends the next file under the same name to the same place, which
+        # would replace this one, so it would be approved with someone else's
+        # content and owner (#1430).
+        received_name = file_path.name
+        waiting = (
+            self.upload_dir / "pending" / uuid.uuid4().hex / received_name
+        )  # SEC-PATH-OK: received_name = the FTP upload's Path(arg).name (a basename), folder a fresh uuid4
+        try:
+            waiting.parent.mkdir(parents=True, exist_ok=True)
+            file_path.replace(waiting)
+            file_path = waiting
+        except OSError as e:
+            logger.error("[VP %s] Could not keep %s for review: %s", self.name, received_name, e)
+            self._pending_files.pop(received_name, None)
+            file_path.unlink(missing_ok=True)
+            return
+
         try:
         try:
             from backend.app.models.pending_upload import PendingUpload
             from backend.app.models.pending_upload import PendingUpload
 
 
@@ -847,6 +907,7 @@ class VirtualPrinterInstance:
                     file_size=file_path.stat().st_size,
                     file_size=file_path.stat().st_size,
                     source_ip=source_ip,
                     source_ip=source_ip,
                     virtual_printer_id=self.id,
                     virtual_printer_id=self.id,
+                    uploaded_by_id=user_id,
                     status="pending",
                     status="pending",
                     uploaded_at=datetime.now(timezone.utc),
                     uploaded_at=datetime.now(timezone.utc),
                     metadata_print_name=metadata_print_name,
                     metadata_print_name=metadata_print_name,
@@ -865,9 +926,9 @@ class VirtualPrinterInstance:
         finally:
         finally:
             # Always drop it from the pending count, also after a queue
             # Always drop it from the pending count, also after a queue
             # failure.
             # failure.
-            self._pending_files.pop(file_path.name, None)
+            self._pending_files.pop(received_name, None)
 
 
-    async def _add_to_print_queue(self, file_path: Path, source_ip: str) -> None:
+    async def _add_to_print_queue(self, file_path: Path, source_ip: str, user_id: int | None = None) -> None:
         """Store the file in the File Manager and queue it, for the target printer or model."""
         """Store the file in the File Manager and queue it, for the target printer or model."""
         if not self._session_factory:
         if not self._session_factory:
             logger.error("Cannot add to print queue: no database session factory configured")
             logger.error("Cannot add to print queue: no database session factory configured")
@@ -988,6 +1049,22 @@ class VirtualPrinterInstance:
                 # without this the setting could never reach it (#1898).
                 # without this the setting could never reach it (#1898).
                 confirm_outcome = await confirm_outcome_for_new_queue_item(db, started_outside_bambuddy=True)
                 confirm_outcome = await confirm_outcome_for_new_queue_item(db, started_outside_bambuddy=True)
 
 
+                # Sent with a personal access code (#1430): the jobs are that
+                # user's, and wait for a reviewer if their jobs must (#1620),
+                # as when they queue from Bambuddy itself.
+                review_required = False
+                if user_id is not None:
+                    from sqlalchemy import select as _select
+                    from sqlalchemy.orm import selectinload
+
+                    from backend.app.core.auth import queue_review_required_for
+                    from backend.app.models.user import User
+
+                    sender = (
+                        await db.execute(_select(User).where(User.id == user_id).options(selectinload(User.groups)))
+                    ).scalar_one_or_none()
+                    review_required = queue_review_required_for(sender)
+
                 # H2C dual-nozzle-rack slicer-pick preservation (#1780).
                 # H2C dual-nozzle-rack slicer-pick preservation (#1780).
                 # BambuStudio's project_file MQTT command for rack-swap models
                 # BambuStudio's project_file MQTT command for rack-swap models
                 # (O1C2 today) carries `nozzle_mapping` — a per-filament array
                 # (O1C2 today) carries `nozzle_mapping` — a per-filament array
@@ -1094,6 +1171,7 @@ class VirtualPrinterInstance:
                     source_path=file_path,
                     source_path=file_path,
                     filename=file_path.name,
                     filename=file_path.name,
                     folder_id=folder_id,
                     folder_id=folder_id,
+                    owner_id=user_id,
                     reuse=not remove_after_print,
                     reuse=not remove_after_print,
                 )
                 )
                 if library_file:
                 if library_file:
@@ -1211,13 +1289,15 @@ class VirtualPrinterInstance:
                             printer_id=self.target_printer_id,
                             printer_id=self.target_printer_id,
                             target_model=target_model,
                             target_model=target_model,
                             library_file_id=library_file.id,
                             library_file_id=library_file.id,
+                            # Theirs when a personal access code signed in (#1430).
+                            created_by_id=user_id,
                             # "Remove from File Manager once it prints": the
                             # "Remove from File Manager once it prints": the
                             # dispatch moves the file into the archive.
                             # dispatch moves the file into the archive.
                             cleanup_library_after_dispatch=remove_after_print,
                             cleanup_library_after_dispatch=remove_after_print,
                             plate_id=plate_id,
                             plate_id=plate_id,
                             position=max_pos + offset,
                             position=max_pos + offset,
                             status="pending",
                             status="pending",
-                            manual_start=not self.auto_dispatch,
+                            manual_start=not self.auto_dispatch or review_required,
                             required_filament_types=required_filament_types_json,
                             required_filament_types=required_filament_types_json,
                             filament_overrides=filament_overrides_json,
                             filament_overrides=filament_overrides_json,
                             bed_levelling=bed_levelling,
                             bed_levelling=bed_levelling,
@@ -1410,6 +1490,7 @@ class VirtualPrinterInstance:
             vp_name=self.name,
             vp_name=self.name,
             passive_port_min=passive_port_min,
             passive_port_min=passive_port_min,
             passive_port_max=passive_port_max,
             passive_port_max=passive_port_max,
+            authenticate_user=self.authenticate_user_code,
         )
         )
         self._tasks.append(
         self._tasks.append(
             asyncio.create_task(
             asyncio.create_task(
@@ -1428,6 +1509,8 @@ class VirtualPrinterInstance:
             model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
             model=self.model or DEFAULT_VIRTUAL_PRINTER_MODEL,
             bind_address=bind_addr,
             bind_address=bind_addr,
             vp_name=self.name,
             vp_name=self.name,
+            authenticate_user=self.authenticate_user_code,
+            authorize_control=self.user_may_control_target,
         )
         )
         self._tasks.append(
         self._tasks.append(
             asyncio.create_task(
             asyncio.create_task(

+ 49 - 7
backend/app/services/virtual_printer/mqtt_server.py

@@ -12,7 +12,7 @@ import logging
 import socket
 import socket
 import ssl
 import ssl
 import time
 import time
-from collections.abc import Callable
+from collections.abc import Awaitable, Callable
 from pathlib import Path
 from pathlib import Path
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 
 
@@ -106,9 +106,20 @@ class SimpleMQTTServer:
         model: str = "",
         model: str = "",
         bind_address: str = "0.0.0.0",  # nosec B104
         bind_address: str = "0.0.0.0",  # nosec B104
         vp_name: str = "",
         vp_name: str = "",
+        authenticate_user: Callable[[str], Awaitable[int | None]] | None = None,
+        authorize_control: Callable[[int], Awaitable[bool]] | None = None,
     ):
     ):
         self.serial = serial
         self.serial = serial
         self.access_code = access_code
         self.access_code = access_code
+        # A personal access code (#1430): returns the user's id, or None.
+        self.authenticate_user = authenticate_user
+        # Whether that user may send commands to the target printer now. The
+        # bridge forwards a slicer's commands (stop, pause, G-code, AMS) to the
+        # real printer; a personal code alone only lets someone send files.
+        self.authorize_control = authorize_control
+        # The user each connection signed in as, for those that used a
+        # personal code (keyed by the connection's writer).
+        self._connection_users: dict[asyncio.StreamWriter, int] = {}
         self.model = model
         self.model = model
         self.cert_path = cert_path
         self.cert_path = cert_path
         self.key_path = key_path
         self.key_path = key_path
@@ -469,11 +480,13 @@ class SimpleMQTTServer:
                         writer.write(bytes([0x20, 0x02, 0x00, 0x05]))  # Not authorized
                         writer.write(bytes([0x20, 0x02, 0x00, 0x05]))  # Not authorized
                         await writer.drain()
                         await writer.drain()
                         break
                         break
+                    # Counted before the lookup, so connections in parallel
+                    # can't all pass the check above first.
+                    self._record_auth_failure(source_ip)
                     authenticated, keep_alive = await self._handle_connect(payload, writer)
                     authenticated, keep_alive = await self._handle_connect(payload, writer)
                     if not authenticated:
                     if not authenticated:
-                        self._record_auth_failure(source_ip)
                         break
                         break
-                    self._clear_auth_failures(source_ip)
+                    self._release_auth_attempt(source_ip)
                     # Drop the application-level read timeout; rely on
                     # Drop the application-level read timeout; rely on
                     # SO_KEEPALIVE below for dead-connection detection.
                     # SO_KEEPALIVE below for dead-connection detection.
                     # Real Bambu firmware does the same — accept any
                     # Real Bambu firmware does the same — accept any
@@ -560,6 +573,7 @@ class SimpleMQTTServer:
             logger.debug("MQTT client disconnected: %s", client_id)
             logger.debug("MQTT client disconnected: %s", client_id)
             self._clients.pop(client_id, None)
             self._clients.pop(client_id, None)
             self._client_serials.pop(client_id, None)
             self._client_serials.pop(client_id, None)
+            self._connection_users.pop(writer, None)
             try:
             try:
                 writer.close()
                 writer.close()
                 await writer.wait_closed()
                 await writer.wait_closed()
@@ -655,9 +669,17 @@ class SimpleMQTTServer:
         now = _time.monotonic()
         now = _time.monotonic()
         self._auth_failures.setdefault(source_ip, []).append(now)
         self._auth_failures.setdefault(source_ip, []).append(now)
 
 
-    def _clear_auth_failures(self, source_ip: str) -> None:
-        """Reset ``source_ip``'s failure history after a successful auth."""
-        self._auth_failures.pop(source_ip, None)
+    def _release_auth_attempt(self, source_ip: str) -> None:
+        """Take back the one attempt counted ahead of a sign-in that succeeded.
+
+        Not the failures before it: a good sign-in wiping them let a user with
+        a code of their own guess others' codes four at a time (#1430).
+        """
+        attempts = self._auth_failures.get(source_ip)
+        if attempts:
+            attempts.pop()
+            if not attempts:
+                self._auth_failures.pop(source_ip, None)
 
 
     async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> tuple[bool, int]:
     async def _handle_connect(self, payload: bytes, writer: asyncio.StreamWriter) -> tuple[bool, int]:
         """Handle MQTT CONNECT packet.
         """Handle MQTT CONNECT packet.
@@ -707,7 +729,13 @@ class SimpleMQTTServer:
             # the auth check from leaking the access code via response timing
             # the auth check from leaking the access code via response timing
             # under network jitter — LAN-only threat is marginal, but it's
             # under network jitter — LAN-only threat is marginal, but it's
             # the standard fix and costs nothing.
             # the standard fix and costs nothing.
-            if username == "bblp" and hmac.compare_digest(password, self.access_code):
+            authenticated = username == "bblp" and hmac.compare_digest(password, self.access_code)
+            if not authenticated and username == "bblp" and self.authenticate_user is not None:
+                user_id = await self.authenticate_user(password)
+                if user_id is not None:
+                    authenticated = True
+                    self._connection_users[writer] = user_id
+            if authenticated:
                 # Send CONNACK with success
                 # Send CONNACK with success
                 writer.write(bytes([0x20, 0x02, 0x00, 0x00]))
                 writer.write(bytes([0x20, 0x02, 0x00, 0x00]))
                 await writer.drain()
                 await writer.drain()
@@ -1313,6 +1341,20 @@ class SimpleMQTTServer:
 
 
             # Forward anything the synthetic flow didn't handle to the real
             # Forward anything the synthetic flow didn't handle to the real
             # printer. AMS load / dry / xcam / system / extrusion_cali_get etc.
             # printer. AMS load / dry / xcam / system / extrusion_cali_get etc.
+            user_id = self._connection_users.get(writer)
+            if (
+                not handled_locally
+                and self._bridge is not None
+                and self._bridge.is_active
+                and user_id is not None
+                and (self.authorize_control is None or not await self.authorize_control(user_id))
+            ):
+                logger.info(
+                    "%sMQTT command from user %s not passed to the printer: no printers:control",
+                    self._log_prefix,
+                    user_id,
+                )
+                handled_locally = True
             if not handled_locally and self._bridge is not None and self._bridge.is_active:
             if not handled_locally and self._bridge is not None and self._bridge.is_active:
                 # Remember which client originated this command so the
                 # Remember which client originated this command so the
                 # printer's response goes back only to them (not fanned
                 # printer's response goes back only to them (not fanned

+ 127 - 0
backend/app/services/virtual_printer/user_codes.py

@@ -0,0 +1,127 @@
+"""Personal access codes for virtual printers (#1430).
+
+A slicer signs in to a printer with one shared access code, so a virtual
+printer could never tell who sent a file. Each user can have a code of their
+own: entered in their slicer instead of the virtual printer's code, it is the
+same sign-in for the slicer, and Bambuddy knows the upload is theirs.
+
+The code is checked at every sign-in, against what the user may do now: the
+account must be active, hold the permissions the virtual printer's mode needs
+and, for a virtual printer with a target printer, be allowed that printer
+(#1727). So deactivating someone, removing a permission or replacing a code
+takes effect at the next sign-in. Only while auth is enabled; never for proxy
+mode, whose sign-in goes to the real printer.
+"""
+
+import logging
+import secrets
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.models.virtual_printer import VP_MODE_LIBRARY, VP_MODE_QUEUE, VP_MODE_REVIEW, normalize_vp_mode
+
+logger = logging.getLogger(__name__)
+
+CODE_LENGTH = 8
+
+# What a user must be allowed to do to send to a virtual printer in a mode:
+# every mode stores the file in the File Manager (review mode once approved),
+# queue mode also queues it.
+MODE_PERMISSIONS: dict[str, tuple[str, ...]] = {
+    VP_MODE_LIBRARY: (Permission.LIBRARY_UPLOAD.value,),
+    VP_MODE_REVIEW: (Permission.LIBRARY_UPLOAD.value,),
+    VP_MODE_QUEUE: (Permission.LIBRARY_UPLOAD.value, Permission.QUEUE_CREATE.value),
+}
+
+
+async def _code_in_use(db: AsyncSession, code: str) -> bool:
+    from backend.app.models.printer import Printer
+    from backend.app.models.virtual_printer import VirtualPrinter
+
+    for column in (User.slicer_access_code, VirtualPrinter.access_code, Printer.access_code):
+        if (await db.execute(select(column).where(column == code).limit(1))).first() is not None:
+            return True
+    return False
+
+
+async def new_code(db: AsyncSession) -> str:
+    """A fresh code: 8 digits like a printer's, used by no user, virtual printer or printer.
+
+    A virtual printer checks its own code first, so a personal code equal to
+    one would never name its user.
+    """
+    for _ in range(50):
+        code = "".join(secrets.choice("0123456789") for _ in range(CODE_LENGTH))
+        if not await _code_in_use(db, code):
+            return code
+    raise RuntimeError("could not find a free access code")
+
+
+async def code_for(db: AsyncSession, user: User, *, renew: bool = False) -> str:
+    """The user's code, made on first use or when ``renew`` (the old one stops working). Commits."""
+    if user.slicer_access_code and not renew:
+        return user.slicer_access_code
+    code = await new_code(db)
+    user.slicer_access_code = code
+    await db.commit()
+    logger.info("New virtual printer access code for user %s", user.id)
+    return code
+
+
+async def user_for_code(db: AsyncSession, code: str, *, mode: str, target_printer_id: int | None) -> User | None:
+    """The user a sign-in with ``code`` stands for, or None to refuse it.
+
+    None also when auth is off, for proxy mode, or when the user may not send
+    to this virtual printer now.
+    """
+    from backend.app.core.auth import is_auth_enabled
+    from backend.app.core.printer_scope import resolve_user_printer_scope
+
+    needed = MODE_PERMISSIONS.get(normalize_vp_mode(mode) or "")
+    if needed is None or len(code) != CODE_LENGTH or not code.isdigit():
+        return None
+    if target_printer_id is not None:
+        # The slicer then shows that printer's live state through the VP.
+        needed = (*needed, Permission.PRINTERS_READ.value)
+    if not await is_auth_enabled(db):
+        return None
+    user = (
+        await db.execute(
+            select(User).where(User.slicer_access_code == code).options(selectinload(User.groups)).limit(1)
+        )
+    ).scalar_one_or_none()
+    if user is None:
+        return None
+    if not user.is_active:
+        logger.info("Virtual printer sign-in refused for user %s: account disabled", user.id)
+        return None
+    if not user.has_all_permissions(*needed):
+        logger.info("Virtual printer sign-in refused for user %s: needs %s", user.id, ", ".join(needed))
+        return None
+    if target_printer_id is not None and not (await resolve_user_printer_scope(db, user)).allows(target_printer_id):
+        logger.info("Virtual printer sign-in refused for user %s: printer %s not theirs", user.id, target_printer_id)
+        return None
+    return user
+
+
+async def may_control_printer(db: AsyncSession, user_id: int, printer_id: int) -> bool:
+    """Whether the user may send commands (stop, pause, G-code, AMS) to the printer now.
+
+    For a slicer signed in to a virtual printer with a personal code: its
+    commands reach the real printer only with printers:control on it.
+    """
+    from backend.app.core.auth import is_auth_enabled
+    from backend.app.core.printer_scope import resolve_user_printer_scope
+
+    if not await is_auth_enabled(db):
+        return False
+    user = (
+        await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
+    ).scalar_one_or_none()
+    if user is None or not user.is_active or not user.has_permission(Permission.PRINTERS_CONTROL.value):
+        return False
+    return (await resolve_user_printer_scope(db, user)).allows(printer_id)

+ 32 - 0
backend/tests/integration/test_pending_upload_archive_project.py

@@ -217,3 +217,35 @@ async def test_approving_needs_library_upload_as_well(
 
 
     assert single.status_code == status, single.text
     assert single.status_code == status, single.text
     assert every.status_code == status, every.text
     assert every.status_code == status, every.text
+
+
+async def test_the_approved_file_is_the_senders(async_client: AsyncClient, db_session, tmp_path):
+    # Sent with a personal access code (#1430): the file is the student's,
+    # not the teacher's who approves it.
+    from backend.app.models.user import User
+
+    student = User(username="student", password_hash="x", is_active=True)
+    db_session.add(student)
+    await db_session.flush()
+    upload_id = (await _pending(db_session, tmp_path, uploaded_by_id=student.id)).id
+
+    response = await async_client.post(f"/api/v1/pending-uploads/{upload_id}/archive")
+
+    assert response.status_code == 200, response.text
+    library_file = await db_session.get(LibraryFile, response.json()["id"])
+    assert library_file.created_by_id == student.id
+
+
+async def test_approving_removes_the_folder_the_upload_waited_in(async_client: AsyncClient, db_session, tmp_path):
+    waiting = tmp_path / "uploads" / "3" / "pending" / "abc123" / "part.3mf"
+    waiting.parent.mkdir(parents=True)
+    waiting.write_bytes(b"3mf")
+    upload = PendingUpload(filename="part.3mf", file_path=str(waiting), file_size=3, source_ip=None, status="pending")
+    db_session.add(upload)
+    await db_session.commit()
+
+    response = await async_client.post(f"/api/v1/pending-uploads/{upload.id}/archive")
+
+    assert response.status_code == 200, response.text
+    assert not waiting.parent.exists()
+    assert waiting.parent.parent.exists()

+ 207 - 0
backend/tests/integration/test_vp_user_codes.py

@@ -0,0 +1,207 @@
+"""Personal virtual-printer access codes: issuing them and the check at sign-in (#1430)."""
+
+import pytest
+from sqlalchemy import insert, select
+
+from backend.app.core.auth import get_password_hash
+from backend.app.models.group import Group, group_printers
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.models.virtual_printer import VirtualPrinter
+from backend.app.services.virtual_printer.user_codes import code_for, new_code, user_for_code
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _auth(db, on: bool = True) -> None:
+    db.add(Settings(key="auth_enabled", value="true" if on else "false"))
+    await db.commit()
+
+
+async def _user(db, permissions, *, active=True, code="11112222", name="student", restrict_to=None) -> User:
+    group = Group(name=f"g-{name}", description="t", permissions=permissions, is_system=False)
+    if restrict_to is not None:
+        group.restrict_printers = True
+    db.add(group)
+    await db.flush()
+    if restrict_to is not None:
+        for printer_id in restrict_to:
+            await db.execute(insert(group_printers).values(group_id=group.id, printer_id=printer_id))
+    user = User(
+        username=name,
+        password_hash=get_password_hash("Pass1word!"),
+        is_active=active,
+        groups=[group],
+        slicer_access_code=code,
+    )
+    db.add(user)
+    await db.commit()
+    return user
+
+
+UPLOAD = ["library:upload"]
+QUEUE = ["library:upload", "queue:create"]
+
+
+@pytest.mark.parametrize(
+    ("mode", "permissions", "allowed"),
+    [
+        ("library", UPLOAD, True),
+        ("review", UPLOAD, True),
+        ("queue", UPLOAD, False),
+        ("queue", QUEUE, True),
+        ("library", ["queue:create"], False),
+        ("archive", UPLOAD, True),  # the old name of library mode
+        ("proxy", QUEUE, False),  # proxy sign-ins go to the real printer
+    ],
+)
+async def test_the_mode_decides_what_the_user_must_be_allowed(db_session, mode, permissions, allowed):
+    await _auth(db_session)
+    user = await _user(db_session, permissions)
+
+    found = await user_for_code(db_session, "11112222", mode=mode, target_printer_id=None)
+
+    assert (found is not None and found.id == user.id) is allowed
+
+
+async def test_refused_while_auth_is_off(db_session):
+    await _auth(db_session, on=False)
+    await _user(db_session, QUEUE)
+
+    assert await user_for_code(db_session, "11112222", mode="queue", target_printer_id=None) is None
+
+
+async def test_refused_for_a_disabled_account_and_an_unknown_code(db_session):
+    await _auth(db_session)
+    await _user(db_session, QUEUE, active=False)
+
+    assert await user_for_code(db_session, "11112222", mode="queue", target_printer_id=None) is None
+    assert await user_for_code(db_session, "99998888", mode="queue", target_printer_id=None) is None
+    assert await user_for_code(db_session, "short", mode="queue", target_printer_id=None) is None
+
+
+async def test_the_target_printer_must_be_in_the_users_scope(db_session, printer_factory):
+    await _auth(db_session)
+    mine = await printer_factory(name="Mine")
+    other = await printer_factory(name="Other")
+    await _user(db_session, [*QUEUE, "printers:read"], restrict_to=[mine.id])
+
+    assert await user_for_code(db_session, "11112222", mode="queue", target_printer_id=mine.id) is not None
+    assert await user_for_code(db_session, "11112222", mode="queue", target_printer_id=other.id) is None
+    # No target: any printer of the model, which the queue checks per printer.
+    assert await user_for_code(db_session, "11112222", mode="queue", target_printer_id=None) is not None
+
+
+async def test_a_code_is_made_once_and_renewed_on_request(db_session):
+    user = await _user(db_session, QUEUE, code=None)
+
+    first = await code_for(db_session, user)
+    again = await code_for(db_session, user)
+    renewed = await code_for(db_session, user, renew=True)
+
+    assert first == again
+    assert renewed != first
+    assert len(renewed) == 8 and renewed.isdigit()
+    assert (await db_session.execute(select(User.slicer_access_code).where(User.id == user.id))).scalar() == renewed
+
+
+async def test_a_new_code_is_nobody_elses(db_session, monkeypatch):
+    # The first candidates are taken by a user and by a virtual printer.
+    await _user(db_session, QUEUE, code="00000001")
+    db_session.add(VirtualPrinter(name="VP", mode="library", access_code="00000002"))
+    await db_session.commit()
+    candidates = iter("00000001" + "00000002" + "00000003")
+    monkeypatch.setattr(
+        "backend.app.services.virtual_printer.user_codes.secrets.choice", lambda _digits: next(candidates)
+    )
+
+    assert await new_code(db_session) == "00000003"
+
+
+async def _login(async_client, name="student") -> dict:
+    response = await async_client.post("/api/v1/auth/login", json={"username": name, "password": "Pass1word!"})
+    assert response.status_code == 200, response.text
+    return {"Authorization": f"Bearer {response.json()['access_token']}"}
+
+
+async def test_a_user_reads_and_renews_their_own_code(async_client, db_session):
+    db_session.add(Settings(key="advanced_auth_enabled", value="false"))
+    await _auth(db_session)
+    await _user(db_session, ["library:read_own"], code=None)
+    headers = await _login(async_client)
+
+    first = await async_client.get("/api/v1/virtual-printers/my-access-code", headers=headers)
+    again = await async_client.get("/api/v1/virtual-printers/my-access-code", headers=headers)
+    renewed = await async_client.post("/api/v1/virtual-printers/my-access-code", headers=headers)
+
+    assert first.status_code == 200, first.text
+    assert first.json() == again.json()
+    assert renewed.json()["code"] != first.json()["code"]
+
+
+async def test_no_code_without_signing_in(async_client, db_session):
+    await _auth(db_session)
+
+    response = await async_client.get("/api/v1/virtual-printers/my-access-code")
+
+    assert response.status_code == 401
+
+
+async def test_an_approved_upload_and_a_reused_file_stay_the_senders(db_session, tmp_path, monkeypatch):
+    # The File Manager file is the sender's, whoever approves it, and a later
+    # upload by someone else never takes it over.
+    from backend.app.core.config import settings
+    from backend.app.services.virtual_printer.library_store import store_upload
+
+    monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
+    student = await _user(db_session, UPLOAD, name="student")
+    other = await _user(db_session, UPLOAD, name="other", code="33334444")
+    source = tmp_path / "plate.3mf"
+    source.write_bytes(b"plate")
+
+    mine, _ = await store_upload(
+        db_session, source_path=source, filename="plate.3mf", folder_id=None, owner_id=student.id
+    )
+    theirs, reused = await store_upload(
+        db_session, source_path=source, filename="plate.3mf", folder_id=None, owner_id=other.id
+    )
+    again, reused_again = await store_upload(
+        db_session, source_path=source, filename="plate.3mf", folder_id=None, owner_id=student.id
+    )
+
+    assert mine.created_by_id == student.id
+    assert reused is False and theirs.created_by_id == other.id
+    assert reused_again is True and again.id == mine.id
+
+
+async def test_a_vp_with_a_target_printer_needs_printers_read_too(db_session, printer_factory):
+    # The slicer shows that printer's live state through the VP.
+    await _auth(db_session)
+    printer = await printer_factory(name="Mine")
+    await _user(db_session, UPLOAD)
+
+    assert await user_for_code(db_session, "11112222", mode="library", target_printer_id=None) is not None
+    assert await user_for_code(db_session, "11112222", mode="library", target_printer_id=printer.id) is None
+
+
+@pytest.mark.parametrize(
+    ("permissions", "active", "in_scope", "allowed"),
+    [
+        (["printers:control"], True, True, True),
+        (["printers:read"], True, True, False),
+        (["printers:control"], False, True, False),
+        (["printers:control"], True, False, False),
+    ],
+    ids=["control", "read-only", "disabled", "other-printer"],
+)
+async def test_commands_to_the_printer_need_printers_control_on_it(
+    db_session, printer_factory, permissions, active, in_scope, allowed
+):
+    from backend.app.services.virtual_printer.user_codes import may_control_printer
+
+    await _auth(db_session)
+    mine = await printer_factory(name="Mine")
+    other = await printer_factory(name="Other")
+    user = await _user(db_session, permissions, active=active, restrict_to=[mine.id])
+
+    assert await may_control_printer(db_session, user.id, mine.id if in_scope else other.id) is allowed

+ 103 - 3
backend/tests/unit/services/test_virtual_printer.py

@@ -210,7 +210,7 @@ class TestVirtualPrinterInstance:
         with patch.object(instance, "_store_in_library", new_callable=AsyncMock) as mock_archive:
         with patch.object(instance, "_store_in_library", new_callable=AsyncMock) as mock_archive:
             await instance.on_file_received(file_path, "192.168.1.100")
             await instance.on_file_received(file_path, "192.168.1.100")
 
 
-            mock_archive.assert_called_once_with(file_path, "192.168.1.100")
+            mock_archive.assert_called_once_with(file_path, "192.168.1.100", user_id=None)
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_on_file_received_signals_FINISH_to_slicer(self, instance):
     async def test_on_file_received_signals_FINISH_to_slicer(self, instance):
@@ -1292,6 +1292,97 @@ class TestVirtualPrinterInstance:
         assert file_path.name not in inst._pending_files
         assert file_path.name not in inst._pending_files
         assert not file_path.exists()
         assert not file_path.exists()
 
 
+    # Personal access codes (#1430): the user the FTP sign-in named.
+
+    @staticmethod
+    def _mock_factory(added=None):
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=(added.append if added is not None else None))
+        ctx = AsyncMock()
+        ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        ctx.__aexit__ = AsyncMock(return_value=False)
+        return MagicMock(return_value=ctx)
+
+    def _inst(self, tmp_path, mode, **kwargs):
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        return VirtualPrinterInstance(
+            vp_id=50,
+            name="Codes",
+            mode=mode,
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800050",
+            base_dir=tmp_path,
+            session_factory=self._mock_factory(kwargs.pop("added", None)),
+            **kwargs,
+        )
+
+    @pytest.mark.asyncio
+    async def test_library_mode_stores_the_file_as_the_senders(self, tmp_path, _fake_file_manager_store):
+        inst = self._inst(tmp_path, "library")
+        file_path = tmp_path / "plate.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        await inst.on_file_received(file_path, "192.168.1.100", user_id=7)
+
+        assert _fake_file_manager_store.await_args.kwargs["owner_id"] == 7
+
+    @pytest.mark.asyncio
+    async def test_review_mode_records_the_sender(self, tmp_path):
+        added = []
+        inst = self._inst(tmp_path, "review", added=added)
+        file_path = tmp_path / "plate.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        await inst.on_file_received(file_path, "192.168.1.100", user_id=7)
+
+        assert added[0].uploaded_by_id == 7
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(("review_required", "manual_start"), [(False, False), (True, True)])
+    async def test_queue_mode_jobs_are_the_senders_and_wait_for_review_if_theirs_must(
+        self, tmp_path, _fake_file_manager_store, review_required, manual_start
+    ):
+        added = []
+        inst = self._inst(tmp_path, "queue", added=added, auto_dispatch=True)
+        file_path = tmp_path / "plate.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        with (
+            patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
+            patch("backend.app.core.auth.queue_review_required_for", return_value=review_required),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100", user_id=7)
+
+        assert _fake_file_manager_store.await_args.kwargs["owner_id"] == 7
+        assert added[0].created_by_id == 7
+        assert added[0].manual_start is manual_start
+
+    @pytest.mark.asyncio
+    async def test_a_personal_code_is_looked_up_for_this_vps_mode_and_target(self, tmp_path):
+        inst = self._inst(tmp_path, "queue", target_printer_id=3)
+        lookup = AsyncMock(return_value=MagicMock(id=7))
+
+        with patch("backend.app.services.virtual_printer.user_codes.user_for_code", lookup):
+            assert await inst.authenticate_user_code("87654321") == 7
+
+        assert lookup.await_args.kwargs == {"mode": "queue", "target_printer_id": 3}
+
+    @pytest.mark.asyncio
+    async def test_no_personal_codes_for_a_proxy_or_when_the_lookup_fails(self, tmp_path):
+        proxy = self._inst(tmp_path, "proxy")
+        lookup = AsyncMock(return_value=MagicMock(id=7))
+        with patch("backend.app.services.virtual_printer.user_codes.user_for_code", lookup):
+            assert await proxy.authenticate_user_code("87654321") is None
+        lookup.assert_not_awaited()
+
+        broken = self._inst(tmp_path, "library")
+        with patch(
+            "backend.app.services.virtual_printer.user_codes.user_for_code", AsyncMock(side_effect=RuntimeError("db"))
+        ):
+            assert await broken.authenticate_user_code("87654321") is None
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_queue_file_records_the_virtual_printer(self, tmp_path):
     async def test_queue_file_records_the_virtual_printer(self, tmp_path):
         """Review mode: the pending upload names the VP it came in on, so
         """Review mode: the pending upload names the VP it came in on, so
@@ -1318,11 +1409,20 @@ class TestVirtualPrinterInstance:
         file_path = tmp_path / "review.3mf"
         file_path = tmp_path / "review.3mf"
         file_path.write_bytes(b"fake3mf")
         file_path.write_bytes(b"fake3mf")
 
 
+        await inst._queue_file(file_path, "192.168.1.100")
+        # The slicer sends the next plate under the same name, to the same place.
+        file_path.write_bytes(b"other3mf")
         await inst._queue_file(file_path, "192.168.1.100")
         await inst._queue_file(file_path, "192.168.1.100")
 
 
-        assert len(added) == 1
+        assert len(added) == 2
         assert added[0].virtual_printer_id == 42
         assert added[0].virtual_printer_id == 42
-        assert file_path.exists(), "the file waits for the review"
+        assert [a.filename for a in added] == ["review.3mf", "review.3mf"]
+        # Each waits in a folder of its own, so neither replaced the other.
+        first, second = Path(added[0].file_path), Path(added[1].file_path)
+        assert first != second
+        assert first.read_bytes() == b"fake3mf"
+        assert second.read_bytes() == b"other3mf"
+        assert not file_path.exists()
 
 
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_queue_file_failure_path_pops_pending_and_unlinks(self, tmp_path):
     async def test_queue_file_failure_path_pops_pending_and_unlinks(self, tmp_path):

+ 10 - 8
backend/tests/unit/test_vp_mqtt_server.py

@@ -529,20 +529,22 @@ class TestAuthRateLimit:
         assert server._is_auth_rate_limited("10.0.0.1") is True
         assert server._is_auth_rate_limited("10.0.0.1") is True
         assert server._is_auth_rate_limited("10.0.0.2") is False
         assert server._is_auth_rate_limited("10.0.0.2") is False
 
 
-    def test_successful_auth_clears_failure_history(self, server):
-        """A successful auth must wipe the IP's prior-failures stash so the
-        user isn't penalised for typos that they ultimately corrected."""
+    def test_a_successful_auth_takes_back_only_its_own_attempt(self, server):
+        """Each attempt is counted before the code is checked; a success takes
+        back just that one. Wiping the earlier failures let a user with a code
+        of their own guess other users' personal codes four at a time between
+        their own sign-ins (#1430); typos simply age out of the window."""
         from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS
         from backend.app.services.virtual_printer.mqtt_server import _AUTH_RATE_LIMIT_MAX_ATTEMPTS
 
 
         ip = "192.168.1.50"
         ip = "192.168.1.50"
-        # Build up failures one short of the cap.
         for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS - 1):
         for _ in range(_AUTH_RATE_LIMIT_MAX_ATTEMPTS - 1):
             server._record_auth_failure(ip)
             server._record_auth_failure(ip)
-        # Successful auth must clear them.
-        server._clear_auth_failures(ip)
-        # Now a subsequent failure starts the count over at 1 (well under cap).
+        # A good sign-in: counted ahead, then taken back.
         server._record_auth_failure(ip)
         server._record_auth_failure(ip)
-        assert server._is_auth_rate_limited(ip) is False
+        server._release_auth_attempt(ip)
+        # The earlier failures still count: one more and the IP is out.
+        server._record_auth_failure(ip)
+        assert server._is_auth_rate_limited(ip) is True
 
 
 
 
 class TestPendingRequestRouting:
 class TestPendingRequestRouting:

+ 241 - 0
backend/tests/unit/test_vp_personal_access_codes.py

@@ -0,0 +1,241 @@
+"""Personal access codes at a virtual printer's FTP and MQTT sign-in (#1430).
+
+The VP's own code signs in as before. Any other code is handed to the
+instance's lookup; a user id back means it is that user's personal code, and
+the FTP upload is passed on as theirs. Wrong codes count towards a per-IP
+limit on FTP, as on MQTT.
+"""
+
+import asyncio
+import ssl
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+from backend.app.services.virtual_printer.ftp_server import AuthRateLimiter, FTPSession
+from backend.tests.unit.test_vp_mqtt_server import _build_connect_payload, _make_server
+
+
+def _writer():
+    writer = MagicMock()
+    writer.write = MagicMock()
+    writer.drain = AsyncMock()
+    writer.get_extra_info = MagicMock(return_value=("192.168.1.99", 12345))
+    return writer
+
+
+def _session(tmp_path, *, authenticate_user=None, rate_limiter=None, on_file_received=None) -> FTPSession:
+    session = FTPSession(
+        reader=asyncio.StreamReader(),
+        writer=_writer(),
+        upload_dir=tmp_path,
+        access_code="12345678",
+        ssl_context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER),
+        on_file_received=on_file_received,
+        bind_address="127.0.0.1",
+        vp_name="codes-test",
+        authenticate_user=authenticate_user,
+        rate_limiter=rate_limiter,
+    )
+    session.username = "bblp"
+    return session
+
+
+def _replies(session) -> list[str]:
+    return [call.args[0].decode() for call in session.writer.write.call_args_list]
+
+
+class TestFtpSignIn:
+    @pytest.mark.asyncio
+    async def test_the_vp_code_signs_in_without_a_user(self, tmp_path):
+        lookup = AsyncMock(return_value=7)
+        session = _session(tmp_path, authenticate_user=lookup)
+
+        await session.cmd_PASS("12345678")
+
+        assert session.authenticated and session.user_id is None
+        lookup.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_personal_code_signs_in_as_its_user(self, tmp_path):
+        lookup = AsyncMock(return_value=7)
+        session = _session(tmp_path, authenticate_user=lookup)
+
+        await session.cmd_PASS("87654321")
+
+        lookup.assert_awaited_once_with("87654321")
+        assert session.authenticated and session.user_id == 7
+        assert _replies(session)[-1].startswith("230")
+
+    @pytest.mark.asyncio
+    async def test_a_code_nobody_has_is_refused(self, tmp_path):
+        session = _session(tmp_path, authenticate_user=AsyncMock(return_value=None))
+
+        await session.cmd_PASS("87654321")
+
+        assert not session.authenticated
+        assert _replies(session)[-1].startswith("530")
+
+    @pytest.mark.parametrize(("user_id", "expected_kwargs"), [(7, {"user_id": 7}), (None, {})])
+    @pytest.mark.asyncio
+    async def test_the_upload_is_passed_on_as_the_users(self, tmp_path, user_id, expected_kwargs):
+        from backend.tests.unit.test_vp_ftp_stor import _make_session, _valid_3mf_bytes
+
+        session = _make_session(tmp_path, data_chunks=[_valid_3mf_bytes()])
+        session.send = AsyncMock()
+        session.user_id = user_id
+        session.on_file_received = AsyncMock()
+
+        await session.cmd_STOR("plate.gcode.3mf")
+
+        args, kwargs = session.on_file_received.call_args
+        assert args == (session.upload_dir / "plate.gcode.3mf", "192.168.1.99")
+        assert kwargs == expected_kwargs
+
+    @pytest.mark.asyncio
+    async def test_five_wrong_codes_lock_the_address_out_even_for_the_right_one(self, tmp_path):
+        limiter = AuthRateLimiter(max_attempts=5, window_seconds=60)
+        for _ in range(5):
+            await _session(tmp_path, rate_limiter=limiter).cmd_PASS("00000000")
+
+        locked = _session(tmp_path, rate_limiter=limiter, authenticate_user=AsyncMock(return_value=7))
+        await locked.cmd_PASS("12345678")
+
+        assert not locked.authenticated
+        locked.authenticate_user.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_good_sign_in_does_not_wipe_earlier_failures(self, tmp_path):
+        # Four wrong codes, one good one, one wrong: the IP is out. Wiping on
+        # success let a user guess others' codes between their own sign-ins.
+        limiter = AuthRateLimiter(max_attempts=5, window_seconds=60)
+        for _ in range(4):
+            await _session(tmp_path, rate_limiter=limiter).cmd_PASS("00000000")
+        good = _session(tmp_path, rate_limiter=limiter)
+        await good.cmd_PASS("12345678")
+        assert good.authenticated
+        await _session(tmp_path, rate_limiter=limiter).cmd_PASS("00000000")
+
+        assert limiter.is_limited("192.168.1.99")
+
+    @pytest.mark.asyncio
+    async def test_no_second_sign_in_on_a_session_already_in(self, tmp_path):
+        lookup = AsyncMock(return_value=7)
+        session = _session(tmp_path, authenticate_user=lookup)
+        await session.cmd_PASS("12345678")
+
+        await session.cmd_PASS("87654321")
+
+        lookup.assert_not_awaited()
+        assert session.user_id is None
+        assert _replies(session)[-1].startswith("503")
+
+    @pytest.mark.asyncio
+    async def test_parallel_sign_ins_are_counted_before_the_lookup(self, tmp_path):
+        # The lookup awaits the database; attempts still in flight must count,
+        # or many parallel connections all pass the limit check first.
+        limiter = AuthRateLimiter(max_attempts=5, window_seconds=60)
+        gate = asyncio.Event()
+        lookups = 0
+
+        async def slow_lookup(_code):
+            nonlocal lookups
+            lookups += 1
+            await gate.wait()
+            return None
+
+        sessions = [_session(tmp_path, rate_limiter=limiter, authenticate_user=slow_lookup) for _ in range(8)]
+        tasks = [asyncio.create_task(s.cmd_PASS("87654321")) for s in sessions]
+        await asyncio.sleep(0.01)
+        gate.set()
+        await asyncio.gather(*tasks)
+
+        assert lookups == 5
+        assert all(not s.authenticated for s in sessions)
+
+
+class TestMqttSignIn:
+    def test_a_personal_code_signs_in(self):
+        server = _make_server()
+        server.authenticate_user = AsyncMock(return_value=7)
+
+        result = asyncio.run(server._handle_connect(_build_connect_payload(60, access_code="87654321"), _writer()))
+
+        assert result == (True, 60)
+        server.authenticate_user.assert_awaited_once_with("87654321")
+
+    def test_a_code_nobody_has_is_refused(self):
+        server = _make_server()
+        server.authenticate_user = AsyncMock(return_value=None)
+
+        result = asyncio.run(server._handle_connect(_build_connect_payload(60, access_code="87654321"), _writer()))
+
+        assert result == (False, 0)
+
+    def test_only_for_the_bblp_user(self):
+        server = _make_server()
+        server.authenticate_user = AsyncMock(return_value=7)
+
+        result = asyncio.run(
+            server._handle_connect(_build_connect_payload(60, access_code="87654321", username="root"), _writer())
+        )
+
+        assert result == (False, 0)
+        server.authenticate_user.assert_not_awaited()
+
+
+class TestMqttBridgeCommands:
+    """A personal code lets someone send files. Commands the bridge would pass
+    to the real printer (stop, pause, G-code, AMS) go only with printers:control,
+    asked fresh for every command; the VP's own code is unchanged."""
+
+    def _publish(self, server, writer, command=None):
+        from backend.tests.unit.test_vp_mqtt_server import _build_publish_payload
+
+        server._client_serials["c1"] = server.serial
+        payload = _build_publish_payload(
+            "device/01P00AABCDEFGHI/request", {"print": command or {"command": "stop", "sequence_id": "1"}}
+        )
+        asyncio.run(server._handle_publish(0x30, payload, writer, "c1"))
+
+    def _server(self, authorize_control):
+        server = _make_server()
+        server._bridge = MagicMock(is_active=True)
+        server.authorize_control = authorize_control
+        return server
+
+    def test_a_personal_code_without_control_reaches_no_printer(self):
+        server = self._server(AsyncMock(return_value=False))
+        writer = _writer()
+        server._connection_users[writer] = 7
+
+        self._publish(server, writer)
+
+        server.authorize_control.assert_awaited_once_with(7)
+        server._bridge.forward_to_printer.assert_not_called()
+
+    def test_with_control_it_does(self):
+        server = self._server(AsyncMock(return_value=True))
+        writer = _writer()
+        server._connection_users[writer] = 7
+
+        self._publish(server, writer)
+
+        server._bridge.forward_to_printer.assert_called_once()
+
+    def test_the_vp_code_needs_no_check(self):
+        server = self._server(AsyncMock(return_value=False))
+
+        self._publish(server, _writer())
+
+        server.authorize_control.assert_not_awaited()
+        server._bridge.forward_to_printer.assert_called_once()
+
+    def test_the_personal_sign_in_is_remembered_for_the_connection(self):
+        server = _make_server()
+        server.authenticate_user = AsyncMock(return_value=7)
+        writer = _writer()
+
+        asyncio.run(server._handle_connect(_build_connect_payload(60, access_code="87654321"), writer))
+
+        assert server._connection_users[writer] == 7

+ 64 - 0
frontend/src/__tests__/components/SlicerAccessCodeModal.test.tsx

@@ -0,0 +1,64 @@
+/**
+ * The signed-in user's personal access code for virtual printers (#1430).
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { SlicerAccessCodeModal } from '../../components/SlicerAccessCodeModal';
+
+vi.mock('../../api/client', () => ({
+  multiVirtualPrinterApi: {
+    getMyAccessCode: vi.fn(),
+    renewMyAccessCode: vi.fn(),
+  },
+  api: { getSettings: vi.fn().mockResolvedValue({}) },
+}));
+
+import { multiVirtualPrinterApi } from '../../api/client';
+
+describe('SlicerAccessCodeModal', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(multiVirtualPrinterApi.getMyAccessCode).mockResolvedValue({ code: '12345678' });
+    vi.mocked(multiVirtualPrinterApi.renewMyAccessCode).mockResolvedValue({ code: '87654321' });
+  });
+
+  it('shows the code hidden until asked', async () => {
+    const user = userEvent.setup();
+    render(<SlicerAccessCodeModal onClose={() => {}} />);
+
+    const code = await screen.findByTestId('slicer-access-code');
+    await waitFor(() => expect(code.textContent).toBe('•'.repeat(8)));
+
+    await user.click(screen.getByRole('button', { name: 'Show code' }));
+
+    expect(screen.getByTestId('slicer-access-code').textContent).toBe('12345678');
+  });
+
+  it('a new code replaces the old one after confirming', async () => {
+    const user = userEvent.setup();
+    render(<SlicerAccessCodeModal onClose={() => {}} />);
+    await screen.findByTestId('slicer-access-code');
+
+    await user.click(await screen.findByRole('button', { name: /New code/ }));
+    const title = await screen.findByText('Replace your access code?');
+    const dialog = title.closest('div.flex-1')?.parentElement?.parentElement as HTMLElement;
+    await user.click(within(dialog).getByRole('button', { name: 'New code' }));
+
+    await waitFor(() => expect(screen.getByTestId('slicer-access-code').textContent).toBe('87654321'));
+    expect(multiVirtualPrinterApi.renewMyAccessCode).toHaveBeenCalledTimes(1);
+  });
+
+  it('cancelling keeps the code', async () => {
+    const user = userEvent.setup();
+    render(<SlicerAccessCodeModal onClose={() => {}} />);
+    await screen.findByTestId('slicer-access-code');
+
+    await user.click(await screen.findByRole('button', { name: /New code/ }));
+    await screen.findByText('Replace your access code?');
+    await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+    expect(multiVirtualPrinterApi.renewMyAccessCode).not.toHaveBeenCalled();
+  });
+});

+ 4 - 0
frontend/src/api/client.ts

@@ -9354,6 +9354,10 @@ export const multiVirtualPrinterApi = {
 
 
   // A script that adds the CA to Bambu Studio and OrcaSlicer, with the CA
   // A script that adds the CA to Bambu Studio and OrcaSlicer, with the CA
   // written into it: 'windows' (.cmd) or 'unix' (.sh, Linux and macOS).
   // written into it: 'windows' (.cmd) or 'unix' (.sh, Linux and macOS).
+  // The signed-in user's personal access code (#1430); renewing replaces it.
+  getMyAccessCode: () => request<{ code: string }>('/virtual-printers/my-access-code'),
+  renewMyAccessCode: () => request<{ code: string }>('/virtual-printers/my-access-code', { method: 'POST' }),
+
   getCaCertificateInstaller: (platform: 'windows' | 'unix') =>
   getCaCertificateInstaller: (platform: 'windows' | 'unix') =>
     request<{ filename: string; content: string }>(`/virtual-printers/ca-certificate/installer/${platform}`),
     request<{ filename: string; content: string }>(`/virtual-printers/ca-certificate/installer/${platform}`),
 
 

+ 22 - 3
frontend/src/components/Layout.tsx

@@ -2,6 +2,7 @@ import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from 'rea
 import { PageLoading } from './PageLoading';
 import { PageLoading } from './PageLoading';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, Megaphone, type LucideIcon } from 'lucide-react';
 import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, Megaphone, type LucideIcon } from 'lucide-react';
+import { SlicerAccessCodeModal } from './SlicerAccessCodeModal';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -114,6 +115,7 @@ export function Layout() {
   const { user, authEnabled, logout, hasPermission, hasAnyPermission, loading: authLoading } = useAuth();
   const { user, authEnabled, logout, hasPermission, hasAnyPermission, loading: authLoading } = useAuth();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
   const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
+  const [showSlicerCodeModal, setShowSlicerCodeModal] = useState(false);
   const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
   const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
   const [changePasswordLoading, setChangePasswordLoading] = useState(false);
   const [changePasswordLoading, setChangePasswordLoading] = useState(false);
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
@@ -831,9 +833,10 @@ export function Layout() {
         <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
         <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
           {isSidebarCompact || sidebarExpanded ? (
           {isSidebarCompact || sidebarExpanded ? (
             <div className="flex flex-col gap-2">
             <div className="flex flex-col gap-2">
-              {/* Top row: icons. 32px each with no gap so seven fit the 239px
-                  of an expanded sidebar -- announcements, System, GitHub,
-                  shortcuts, theme, password, logout -- without wrapping. */}
+              {/* Top row: icons, 32px each with no gap: seven fit the 239px of
+                  an expanded sidebar -- announcements, System, GitHub,
+                  shortcuts, theme, password, logout. Signed in, the slicer
+                  access code (#1430) makes eight, which wrap to a second row. */}
               <div className="flex items-center justify-center flex-wrap [&>a]:p-1.5 [&>button]:p-1.5 [&>span]:p-1.5 [&>div>button]:p-1.5">
               <div className="flex items-center justify-center flex-wrap [&>a]:p-1.5 [&>button]:p-1.5 [&>span]:p-1.5 [&>div>button]:p-1.5">
                 {hasSwitchbarPlugs && (
                 {hasSwitchbarPlugs && (
                   <div className="relative">
                   <div className="relative">
@@ -923,6 +926,13 @@ export function Layout() {
                     >
                     >
                       <Key className="w-5 h-5" />
                       <Key className="w-5 h-5" />
                     </button>
                     </button>
+                    <button
+                      onClick={() => setShowSlicerCodeModal(true)}
+                      className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                      title={t('slicerAccessCode.title')}
+                    >
+                      <Printer className="w-5 h-5" />
+                    </button>
                     <button
                     <button
                       onClick={logout}
                       onClick={logout}
                       className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
                       className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
@@ -1045,6 +1055,13 @@ export function Layout() {
                   >
                   >
                     <Key className="w-5 h-5" />
                     <Key className="w-5 h-5" />
                   </button>
                   </button>
+                  <button
+                    onClick={() => setShowSlicerCodeModal(true)}
+                    className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                    title={t('slicerAccessCode.title')}
+                  >
+                    <Printer className="w-5 h-5" />
+                  </button>
                   <button
                   <button
                     onClick={logout}
                     onClick={logout}
                     className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
                     className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
@@ -1225,6 +1242,8 @@ export function Layout() {
       )}
       )}
 
 
       {/* Change Password Modal */}
       {/* Change Password Modal */}
+      {showSlicerCodeModal && <SlicerAccessCodeModal onClose={() => setShowSlicerCodeModal(false)} />}
+
       {showChangePasswordModal && (
       {showChangePasswordModal && (
         <div
         <div
           className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
           className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"

+ 155 - 0
frontend/src/components/SlicerAccessCodeModal.tsx

@@ -0,0 +1,155 @@
+import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Check, Copy, Eye, EyeOff, Loader2, Printer, RefreshCw, X } from 'lucide-react';
+import { multiVirtualPrinterApi } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+import { copyTextToClipboard } from '../utils/clipboard';
+
+/**
+ * The signed-in user's personal access code for virtual printers (#1430).
+ *
+ * Entered in their slicer instead of a virtual printer's own access code, it
+ * marks what they send as theirs. A virtual printer checks at every sign-in
+ * whether they may send to it.
+ */
+export function SlicerAccessCodeModal({ onClose }: { onClose: () => void }) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const [revealed, setRevealed] = useState(false);
+  const [copied, setCopied] = useState(false);
+  const [confirmRenew, setConfirmRenew] = useState(false);
+  // Per user: on a shared PC the next person to sign in must never see the
+  // code cached for the one before.
+  const { user } = useAuth();
+  const queryKey = ['my-slicer-access-code', user?.id ?? null];
+
+  const { data, isLoading, isError } = useQuery({
+    queryKey,
+    queryFn: multiVirtualPrinterApi.getMyAccessCode,
+    gcTime: 0,
+  });
+
+  const renew = useMutation({
+    mutationFn: multiVirtualPrinterApi.renewMyAccessCode,
+    onSuccess: (result) => {
+      queryClient.setQueryData(queryKey, result);
+      setRevealed(true);
+      showToast(t('slicerAccessCode.renewed'));
+    },
+    onError: (error: Error) => showToast(error.message || t('slicerAccessCode.loadFailed'), 'error'),
+  });
+
+  useEffect(() => {
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && !confirmRenew) onClose();
+    };
+    window.addEventListener('keydown', onKey);
+    return () => window.removeEventListener('keydown', onKey);
+  }, [onClose, confirmRenew]);
+
+  const copy = async () => {
+    if (!data) return;
+    if (await copyTextToClipboard(data.code)) {
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } else {
+      showToast(t('slicerAccessCode.copyFailed'), 'error');
+    }
+  };
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" onClick={onClose}>
+      <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
+        <CardContent className="p-6 space-y-4">
+          <div className="flex items-start justify-between gap-3">
+            <div className="flex items-center gap-2">
+              <Printer className="w-5 h-5 text-bambu-green" />
+              <h3 className="text-lg font-semibold text-white">{t('slicerAccessCode.title')}</h3>
+            </div>
+            <button
+              onClick={onClose}
+              aria-label={t('common.close')}
+              className="p-1 text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          <p className="text-sm text-bambu-gray">{t('slicerAccessCode.description')}</p>
+
+          {isLoading ? (
+            <div className="flex justify-center py-4">
+              <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+            </div>
+          ) : isError || !data ? (
+            <p className="text-sm text-red-600 dark:text-red-400">{t('slicerAccessCode.loadFailed')}</p>
+          ) : (
+            <div className="flex items-center gap-2">
+              <code
+                data-testid="slicer-access-code"
+                className="flex-1 text-center text-2xl font-mono tracking-[0.3em] text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md py-2"
+              >
+                {revealed ? data.code : '•'.repeat(data.code.length)}
+              </code>
+              <button
+                onClick={() => setRevealed(!revealed)}
+                aria-label={revealed ? t('slicerAccessCode.hide') : t('slicerAccessCode.show')}
+                className="p-2 text-bambu-gray hover:text-white transition-colors"
+              >
+                {revealed ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
+              </button>
+              <button
+                onClick={copy}
+                aria-label={t('slicerAccessCode.copy')}
+                className="p-2 text-bambu-gray hover:text-white transition-colors"
+              >
+                {copied ? <Check className="w-5 h-5 text-bambu-green" /> : <Copy className="w-5 h-5" />}
+              </button>
+            </div>
+          )}
+
+          <ul className="text-xs text-bambu-gray list-disc pl-4 space-y-1">
+            <li>{t('slicerAccessCode.hintChecked')}</li>
+            <li>{t('slicerAccessCode.hintCamera')}</li>
+            <li>{t('slicerAccessCode.hintSecret')}</li>
+          </ul>
+
+          <div className="flex justify-end">
+            <Button
+              variant="secondary"
+              size="sm"
+              onClick={() => setConfirmRenew(true)}
+              disabled={!data || renew.isPending}
+            >
+              {renew.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
+              {t('slicerAccessCode.renew')}
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+
+      {confirmRenew && (
+        <div onClick={(e) => e.stopPropagation()}>
+          <ConfirmModal
+            title={t('slicerAccessCode.renewTitle')}
+            message={t('slicerAccessCode.renewConfirm')}
+            confirmText={t('slicerAccessCode.renew')}
+            variant="warning"
+            overlayZIndex="z-[60]"
+            onConfirm={() => {
+              setConfirmRenew(false);
+              renew.mutate();
+            }}
+            onCancel={() => setConfirmRenew(false)}
+          />
+        </div>
+      )}
+    </div>
+  );
+}

+ 11 - 2
frontend/src/components/VirtualPrinterCard.tsx

@@ -1,4 +1,4 @@
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import {
 import {
@@ -89,6 +89,12 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const tailscaleFqdn = tailscaleStatus?.available ? tailscaleStatus.fqdn : '';
   const tailscaleFqdn = tailscaleStatus?.available ? tailscaleStatus.fqdn : '';
   const tailscaleIp = tailscaleStatus?.available ? tailscaleStatus.tailscale_ips?.[0] ?? '' : '';
   const tailscaleIp = tailscaleStatus?.available ? tailscaleStatus.tailscale_ips?.[0] ?? '' : '';
 
 
+  // Cleared on unmount: a timer left running sets state on a card that is gone.
+  const fqdnCopiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
+  useEffect(() => () => {
+    if (fqdnCopiedTimer.current) clearTimeout(fqdnCopiedTimer.current);
+  }, []);
+
   const handleCopyFqdn = async (e: React.MouseEvent) => {
   const handleCopyFqdn = async (e: React.MouseEvent) => {
     e.stopPropagation();
     e.stopPropagation();
     const fqdn = tailscaleFqdn;
     const fqdn = tailscaleFqdn;
@@ -97,7 +103,8 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
     if (ok) {
     if (ok) {
       setFqdnCopied(true);
       setFqdnCopied(true);
       showToast(t('printers.copied'));
       showToast(t('printers.copied'));
-      setTimeout(() => setFqdnCopied(false), 2000);
+      if (fqdnCopiedTimer.current) clearTimeout(fqdnCopiedTimer.current);
+      fqdnCopiedTimer.current = setTimeout(() => setFqdnCopied(false), 2000);
     } else {
     } else {
       showToast(t('virtualPrinter.toast.copyFailed'), 'error');
       showToast(t('virtualPrinter.toast.copyFailed'), 'error');
     }
     }
@@ -765,6 +772,8 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
                     )}
                     )}
                   </>
                   </>
                 )}
                 )}
+                {/* Personal access codes (#1430): users can sign in with their own. */}
+                <p className="text-xs text-bambu-gray mt-1">{t('virtualPrinter.accessCode.personalHint')}</p>
               </div>
               </div>
             )}
             )}
 
 

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

@@ -6345,6 +6345,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: 'Zugangscode',
       title: 'Zugangscode',
+      personalHint: 'Mit aktivierter Anmeldung können Benutzer auch ihren persönlichen Zugangscode eingeben (Seitenleiste, Druckersymbol): Was sie senden, gehört dann ihnen.',
       isSet: 'Zugangscode ist gesetzt',
       isSet: 'Zugangscode ist gesetzt',
       notSet: 'Kein Zugangscode gesetzt - erforderlich zum Aktivieren',
       notSet: 'Kein Zugangscode gesetzt - erforderlich zum Aktivieren',
       placeholder: '8-Zeichen-Code eingeben',
       placeholder: '8-Zeichen-Code eingeben',
@@ -7322,6 +7323,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: 'Slicer-Zugangscode',
+    description: 'Gib diesen Code in Bambu Studio oder OrcaSlicer statt des Zugangscodes des virtuellen Druckers ein. Was du an den virtuellen Drucker sendest, gehört dann dir.',
+    show: 'Code anzeigen',
+    hide: 'Code verbergen',
+    copy: 'Code kopieren',
+    copyFailed: 'Der Code konnte nicht kopiert werden',
+    loadFailed: 'Dein Zugangscode konnte nicht geladen werden',
+    renew: 'Neuer Code',
+    renewTitle: 'Zugangscode ersetzen?',
+    renewConfirm: 'Dein bisheriger Code funktioniert dann nicht mehr. Verbinde den virtuellen Drucker im Slicer mit dem neuen Code neu.',
+    renewed: 'Neuer Zugangscode bereit',
+    hintChecked: 'Bei jeder Anmeldung wird geprüft, ob du an diesen virtuellen Drucker und seinen Drucker senden darfst.',
+    hintCamera: 'Mit diesem Code funktioniert die Kameraansicht des Slicers nicht; die Kamera in Bambuddy schon.',
+    hintSecret: 'Er wirkt wie ein Passwort: gib ihn nicht weiter.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: 'Ausstehende Uploads ({{count}})',
     title: 'Ausstehende Uploads ({{count}})',
     noApprovePermission: 'Zum Ablegen im Dateimanager brauchst du die Berechtigungen queue:create und library:upload',
     noApprovePermission: 'Zum Ablegen im Dateimanager brauchst du die Berechtigungen queue:create und library:upload',

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

@@ -6398,6 +6398,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: 'Access Code',
       title: 'Access Code',
+      personalHint: 'With auth on, users can also enter their personal access code (sidebar, printer icon): what they send is then theirs.',
       isSet: 'Access code is set',
       isSet: 'Access code is set',
       notSet: 'No access code set - required to enable',
       notSet: 'No access code set - required to enable',
       placeholder: 'Enter 8-char code',
       placeholder: 'Enter 8-char code',
@@ -7375,6 +7376,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: 'Slicer access code',
+    description: 'Enter this code in Bambu Studio or OrcaSlicer instead of the virtual printer\'s own access code. What you send to the virtual printer is then yours.',
+    show: 'Show code',
+    hide: 'Hide code',
+    copy: 'Copy code',
+    copyFailed: 'Could not copy the code',
+    loadFailed: 'Could not load your access code',
+    renew: 'New code',
+    renewTitle: 'Replace your access code?',
+    renewConfirm: 'Your current code stops working. Bind the virtual printer in your slicer again with the new code.',
+    renewed: 'New access code ready',
+    hintChecked: 'Every sign-in checks that you may send to that virtual printer and its printer.',
+    hintCamera: 'With this code the slicer\'s own camera view of the printer does not work; Bambuddy\'s camera does.',
+    hintSecret: 'It works like a password: don\'t share it.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: 'Pending uploads ({{count}})',
     title: 'Pending uploads ({{count}})',
     noApprovePermission: 'Adding to the File Manager needs the queue:create and library:upload permissions',
     noApprovePermission: 'Adding to the File Manager needs the queue:create and library:upload permissions',

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

@@ -6353,6 +6353,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: 'Código de acceso',
       title: 'Código de acceso',
+      personalHint: 'Con la autenticación activada, los usuarios también pueden introducir su código de acceso personal (barra lateral, icono de impresora): lo que envíen será suyo.',
       isSet: 'El código de acceso está establecido',
       isSet: 'El código de acceso está establecido',
       notSet: 'No hay código de acceso establecido - necesario para activarla',
       notSet: 'No hay código de acceso establecido - necesario para activarla',
       placeholder: 'Introduzca un código de 8 caracteres',
       placeholder: 'Introduzca un código de 8 caracteres',
@@ -7330,6 +7331,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: 'Código de acceso del laminador',
+    description: 'Introduce este código en Bambu Studio u OrcaSlicer en lugar del código de acceso de la impresora virtual. Lo que envíes a la impresora virtual será tuyo.',
+    show: 'Mostrar código',
+    hide: 'Ocultar código',
+    copy: 'Copiar código',
+    copyFailed: 'No se pudo copiar el código',
+    loadFailed: 'No se pudo cargar tu código de acceso',
+    renew: 'Nuevo código',
+    renewTitle: '¿Reemplazar tu código de acceso?',
+    renewConfirm: 'Tu código actual dejará de funcionar. Vuelve a vincular la impresora virtual en el laminador con el nuevo código.',
+    renewed: 'Nuevo código de acceso listo',
+    hintChecked: 'Cada inicio de sesión comprueba que puedes enviar a esa impresora virtual y a su impresora.',
+    hintCamera: 'Con este código la vista de cámara del laminador no funciona; la cámara de Bambuddy sí.',
+    hintSecret: 'Funciona como una contraseña: no lo compartas.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: 'Subidas pendientes ({{count}})',
     title: 'Subidas pendientes ({{count}})',
     noApprovePermission: 'Añadir al gestor de archivos requiere los permisos queue:create y library:upload',
     noApprovePermission: 'Añadir al gestor de archivos requiere los permisos queue:create y library:upload',

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

@@ -6333,6 +6333,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: 'Code d\'accès',
       title: 'Code d\'accès',
+      personalHint: 'Avec l\'authentification activée, les utilisateurs peuvent aussi saisir leur code d\'accès personnel (barre latérale, icône d\'imprimante) : ce qu\'ils envoient est alors à eux.',
       isSet: 'Code défini',
       isSet: 'Code défini',
       notSet: 'Code requis pour activer',
       notSet: 'Code requis pour activer',
       placeholder: 'Code 8 char',
       placeholder: 'Code 8 char',
@@ -7310,6 +7311,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: 'Code d\'accès du trancheur',
+    description: 'Saisissez ce code dans Bambu Studio ou OrcaSlicer à la place du code d\'accès de l\'imprimante virtuelle. Ce que vous envoyez à l\'imprimante virtuelle est alors à vous.',
+    show: 'Afficher le code',
+    hide: 'Masquer le code',
+    copy: 'Copier le code',
+    copyFailed: 'Impossible de copier le code',
+    loadFailed: 'Impossible de charger votre code d\'accès',
+    renew: 'Nouveau code',
+    renewTitle: 'Remplacer votre code d\'accès ?',
+    renewConfirm: 'Votre code actuel ne fonctionnera plus. Reliez à nouveau l\'imprimante virtuelle dans le trancheur avec le nouveau code.',
+    renewed: 'Nouveau code d\'accès prêt',
+    hintChecked: 'Chaque connexion vérifie que vous pouvez envoyer à cette imprimante virtuelle et à son imprimante.',
+    hintCamera: 'Avec ce code, la vue caméra du trancheur ne fonctionne pas ; celle de Bambuddy, si.',
+    hintSecret: 'Il agit comme un mot de passe : ne le partagez pas.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: 'Envois en attente ({{count}})',
     title: 'Envois en attente ({{count}})',
     noApprovePermission: 'L\'ajout au gestionnaire de fichiers nécessite les permissions queue:create et library:upload',
     noApprovePermission: 'L\'ajout au gestionnaire de fichiers nécessite les permissions queue:create et library:upload',

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

@@ -6332,6 +6332,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: 'Codice accesso',
       title: 'Codice accesso',
+      personalHint: 'Con l\'autenticazione attiva, gli utenti possono inserire anche il loro codice di accesso personale (barra laterale, icona della stampante): ciò che inviano sarà loro.',
       isSet: 'Codice accesso impostato',
       isSet: 'Codice accesso impostato',
       notSet: 'Nessun codice accesso impostato - richiesto per abilitare',
       notSet: 'Nessun codice accesso impostato - richiesto per abilitare',
       placeholder: 'Inserisci codice 8 caratteri',
       placeholder: 'Inserisci codice 8 caratteri',
@@ -7309,6 +7310,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: 'Codice di accesso dello slicer',
+    description: 'Inserisci questo codice in Bambu Studio o OrcaSlicer al posto del codice di accesso della stampante virtuale. Ciò che invii alla stampante virtuale sarà tuo.',
+    show: 'Mostra codice',
+    hide: 'Nascondi codice',
+    copy: 'Copia codice',
+    copyFailed: 'Impossibile copiare il codice',
+    loadFailed: 'Impossibile caricare il tuo codice di accesso',
+    renew: 'Nuovo codice',
+    renewTitle: 'Sostituire il codice di accesso?',
+    renewConfirm: 'Il codice attuale smetterà di funzionare. Collega di nuovo la stampante virtuale nello slicer con il nuovo codice.',
+    renewed: 'Nuovo codice di accesso pronto',
+    hintChecked: 'A ogni accesso si controlla che tu possa inviare a quella stampante virtuale e alla sua stampante.',
+    hintCamera: 'Con questo codice la vista della fotocamera nello slicer non funziona; quella di Bambuddy sì.',
+    hintSecret: 'Funziona come una password: non condividerlo.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: 'Caricamenti in attesa ({{count}})',
     title: 'Caricamenti in attesa ({{count}})',
     noApprovePermission: 'Per aggiungere al gestore file servono i permessi queue:create e library:upload',
     noApprovePermission: 'Per aggiungere al gestore file servono i permessi queue:create e library:upload',

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

@@ -6346,6 +6346,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: 'アクセスコード',
       title: 'アクセスコード',
+      personalHint: '認証が有効なとき、ユーザーは個人用アクセスコード(サイドバーのプリンターアイコン)も入力できます。送ったものはそのユーザーのものになります。',
       isSet: 'アクセスコードが設定されています',
       isSet: 'アクセスコードが設定されています',
       notSet: 'アクセスコード未設定 - 有効化に必要です',
       notSet: 'アクセスコード未設定 - 有効化に必要です',
       placeholder: '8文字のコードを入力',
       placeholder: '8文字のコードを入力',
@@ -7323,6 +7324,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: 'スライサー用アクセスコード',
+    description: '仮想プリンター自体のアクセスコードの代わりに、このコードを Bambu Studio または OrcaSlicer に入力してください。仮想プリンターに送ったものはあなたのものになります。',
+    show: 'コードを表示',
+    hide: 'コードを隠す',
+    copy: 'コードをコピー',
+    copyFailed: 'コードをコピーできませんでした',
+    loadFailed: 'アクセスコードを読み込めませんでした',
+    renew: '新しいコード',
+    renewTitle: 'アクセスコードを置き換えますか?',
+    renewConfirm: '現在のコードは使えなくなります。新しいコードでスライサーの仮想プリンターを再度バインドしてください。',
+    renewed: '新しいアクセスコードを用意しました',
+    hintChecked: 'サインインのたびに、その仮想プリンターとプリンターに送信できるか確認されます。',
+    hintCamera: 'このコードではスライサーのカメラ表示は使えません。Bambuddy のカメラは使えます。',
+    hintSecret: 'パスワードと同じように扱い、共有しないでください。',
+  },
   pendingUploads: {
   pendingUploads: {
     title: '保留中のアップロード ({{count}})',
     title: '保留中のアップロード ({{count}})',
     noApprovePermission: 'ファイル管理への追加には queue:create と library:upload の権限が必要です',
     noApprovePermission: 'ファイル管理への追加には queue:create と library:upload の権限が必要です',

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

@@ -6082,6 +6082,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: '액세스 코드',
       title: '액세스 코드',
+      personalHint: '인증이 켜져 있으면 사용자는 개인 액세스 코드(사이드바의 프린터 아이콘)도 입력할 수 있습니다. 보낸 것은 해당 사용자의 것이 됩니다.',
       isSet: '액세스 코드가 설정됨',
       isSet: '액세스 코드가 설정됨',
       notSet: '액세스 코드가 설정되지 않음 - 활성화하려면 필요',
       notSet: '액세스 코드가 설정되지 않음 - 활성화하려면 필요',
       placeholder: '8자리 코드 입력',
       placeholder: '8자리 코드 입력',
@@ -7016,6 +7017,22 @@ export default {
     includeAccessCodesWarning: '액세스 코드가 평문으로 포함됩니다. 이 백업 파일을 안전하게 보관하세요!',
     includeAccessCodesWarning: '액세스 코드가 평문으로 포함됩니다. 이 백업 파일을 안전하게 보관하세요!',
     categoriesSelected: '{{selectedCount}}개 카테고리 선택됨'
     categoriesSelected: '{{selectedCount}}개 카테고리 선택됨'
   },
   },
+  slicerAccessCode: {
+    title: '슬라이서 액세스 코드',
+    description: '가상 프린터 자체의 액세스 코드 대신 이 코드를 Bambu Studio 또는 OrcaSlicer에 입력하세요. 가상 프린터로 보낸 것은 당신의 것이 됩니다.',
+    show: '코드 표시',
+    hide: '코드 숨기기',
+    copy: '코드 복사',
+    copyFailed: '코드를 복사할 수 없습니다',
+    loadFailed: '액세스 코드를 불러올 수 없습니다',
+    renew: '새 코드',
+    renewTitle: '액세스 코드를 바꿀까요?',
+    renewConfirm: '현재 코드는 더 이상 작동하지 않습니다. 새 코드로 슬라이서에서 가상 프린터를 다시 연결하세요.',
+    renewed: '새 액세스 코드가 준비되었습니다',
+    hintChecked: '로그인할 때마다 해당 가상 프린터와 프린터로 보낼 수 있는지 확인합니다.',
+    hintCamera: '이 코드로는 슬라이서의 카메라 보기가 작동하지 않습니다. Bambuddy의 카메라는 작동합니다.',
+    hintSecret: '비밀번호처럼 다루고 공유하지 마세요.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: '대기 중인 업로드 ({{count}})',
     title: '대기 중인 업로드 ({{count}})',
     noApprovePermission: '파일 관리자에 추가하려면 queue:create 및 library:upload 권한이 필요합니다',
     noApprovePermission: '파일 관리자에 추가하려면 queue:create 및 library:upload 권한이 필요합니다',

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

@@ -6396,6 +6396,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: 'Toegangscode',
       title: 'Toegangscode',
+      personalHint: 'Met aanmelding aan kunnen gebruikers ook hun persoonlijke toegangscode invoeren (zijbalk, printerpictogram): wat ze sturen is dan van hen.',
       isSet: 'Toegangscode is ingesteld',
       isSet: 'Toegangscode is ingesteld',
       notSet: 'Geen toegangscode ingesteld - vereist om in te schakelen',
       notSet: 'Geen toegangscode ingesteld - vereist om in te schakelen',
       placeholder: 'Voer een code van 8 tekens in',
       placeholder: 'Voer een code van 8 tekens in',
@@ -7373,6 +7374,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: 'Slicer-toegangscode',
+    description: 'Voer deze code in Bambu Studio of OrcaSlicer in in plaats van de toegangscode van de virtuele printer. Wat je naar de virtuele printer stuurt, is dan van jou.',
+    show: 'Code tonen',
+    hide: 'Code verbergen',
+    copy: 'Code kopiëren',
+    copyFailed: 'De code kon niet worden gekopieerd',
+    loadFailed: 'Je toegangscode kon niet worden geladen',
+    renew: 'Nieuwe code',
+    renewTitle: 'Toegangscode vervangen?',
+    renewConfirm: 'Je huidige code werkt dan niet meer. Koppel de virtuele printer in de slicer opnieuw met de nieuwe code.',
+    renewed: 'Nieuwe toegangscode klaar',
+    hintChecked: 'Bij elke aanmelding wordt gecontroleerd of je naar die virtuele printer en zijn printer mag sturen.',
+    hintCamera: 'Met deze code werkt het camerabeeld van de slicer niet; de camera van Bambuddy wel.',
+    hintSecret: 'Hij werkt als een wachtwoord: deel hem niet.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: 'Wachtende uploads ({{count}})',
     title: 'Wachtende uploads ({{count}})',
     noApprovePermission: 'Toevoegen aan bestandsbeheer vereist de rechten queue:create en library:upload',
     noApprovePermission: 'Toevoegen aan bestandsbeheer vereist de rechten queue:create en library:upload',

+ 17 - 0
frontend/src/i18n/locales/pl.ts

@@ -6398,6 +6398,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: 'Kod dostępu',
       title: 'Kod dostępu',
+      personalHint: 'Przy włączonym uwierzytelnianiu użytkownicy mogą też wpisać swój osobisty kod dostępu (pasek boczny, ikona drukarki): to, co wyślą, będzie ich.',
       isSet: 'Kod dostępu jest ustawiony',
       isSet: 'Kod dostępu jest ustawiony',
       notSet: 'Brak kodu dostępu — wymagany do włączenia',
       notSet: 'Brak kodu dostępu — wymagany do włączenia',
       placeholder: 'Wpisz 8-znakowy kod',
       placeholder: 'Wpisz 8-znakowy kod',
@@ -7375,6 +7376,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: 'Kod dostępu slicera',
+    description: 'Wpisz ten kod w Bambu Studio lub OrcaSlicer zamiast kodu dostępu wirtualnej drukarki. To, co wyślesz do wirtualnej drukarki, będzie wtedy twoje.',
+    show: 'Pokaż kod',
+    hide: 'Ukryj kod',
+    copy: 'Kopiuj kod',
+    copyFailed: 'Nie udało się skopiować kodu',
+    loadFailed: 'Nie udało się wczytać twojego kodu dostępu',
+    renew: 'Nowy kod',
+    renewTitle: 'Zastąpić kod dostępu?',
+    renewConfirm: 'Obecny kod przestanie działać. Połącz ponownie wirtualną drukarkę w slicerze, używając nowego kodu.',
+    renewed: 'Nowy kod dostępu gotowy',
+    hintChecked: 'Przy każdym logowaniu sprawdzane jest, czy możesz wysyłać do tej wirtualnej drukarki i jej drukarki.',
+    hintCamera: 'Z tym kodem podgląd kamery w slicerze nie działa; kamera w Bambuddy działa.',
+    hintSecret: 'Działa jak hasło: nie udostępniaj go.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: 'Oczekujące przesłania ({{count}})',
     title: 'Oczekujące przesłania ({{count}})',
     noApprovePermission: 'Dodawanie do menedżera plików wymaga uprawnień queue:create i library:upload',
     noApprovePermission: 'Dodawanie do menedżera plików wymaga uprawnień queue:create i library:upload',

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

@@ -6332,6 +6332,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: 'Código de acesso',
       title: 'Código de acesso',
+      personalHint: 'Com a autenticação ativa, os usuários também podem digitar seu código de acesso pessoal (barra lateral, ícone de impressora): o que enviarem passa a ser deles.',
       isSet: 'O código de acesso está definido',
       isSet: 'O código de acesso está definido',
       notSet: 'Nenhum código de acesso definido — necessário para ativar.',
       notSet: 'Nenhum código de acesso definido — necessário para ativar.',
       placeholder: 'Digite um código de 8 caracteres',
       placeholder: 'Digite um código de 8 caracteres',
@@ -7309,6 +7310,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: 'Código de acesso do fatiador',
+    description: 'Digite este código no Bambu Studio ou OrcaSlicer no lugar do código de acesso da impressora virtual. O que você enviar à impressora virtual passa a ser seu.',
+    show: 'Mostrar código',
+    hide: 'Ocultar código',
+    copy: 'Copiar código',
+    copyFailed: 'Não foi possível copiar o código',
+    loadFailed: 'Não foi possível carregar seu código de acesso',
+    renew: 'Novo código',
+    renewTitle: 'Substituir seu código de acesso?',
+    renewConfirm: 'Seu código atual deixará de funcionar. Vincule a impressora virtual no fatiador de novo com o novo código.',
+    renewed: 'Novo código de acesso pronto',
+    hintChecked: 'Cada login verifica se você pode enviar para essa impressora virtual e a impressora dela.',
+    hintCamera: 'Com este código a visualização de câmera do fatiador não funciona; a câmera do Bambuddy funciona.',
+    hintSecret: 'Funciona como uma senha: não compartilhe.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: 'Envios pendentes ({{count}})',
     title: 'Envios pendentes ({{count}})',
     noApprovePermission: 'Adicionar ao Gerenciador de Arquivos exige as permissões queue:create e library:upload',
     noApprovePermission: 'Adicionar ao Gerenciador de Arquivos exige as permissões queue:create e library:upload',

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

@@ -6070,6 +6070,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: "Код доступа",
       title: "Код доступа",
+      personalHint: "При включённой авторизации пользователи могут ввести и свой личный код доступа (боковая панель, значок принтера): отправленное ими будет принадлежать им.",
       isSet: "Код доступа задан",
       isSet: "Код доступа задан",
       notSet: "Код доступа не задан — он необходим для включения",
       notSet: "Код доступа не задан — он необходим для включения",
       placeholder: "Введите 8-значный код",
       placeholder: "Введите 8-значный код",
@@ -7003,6 +7004,22 @@ export default {
     includeAccessCodesWarning: "Коды доступа попадут в файл открытым текстом. Храните резервную копию в безопасном месте!",
     includeAccessCodesWarning: "Коды доступа попадут в файл открытым текстом. Храните резервную копию в безопасном месте!",
     categoriesSelected: "Выбрано категорий: {{selectedCount}}",
     categoriesSelected: "Выбрано категорий: {{selectedCount}}",
   },
   },
+  slicerAccessCode: {
+    title: "Код доступа для слайсера",
+    description: "Введите этот код в Bambu Studio или OrcaSlicer вместо кода доступа виртуального принтера. То, что вы отправите на виртуальный принтер, будет вашим.",
+    show: "Показать код",
+    hide: "Скрыть код",
+    copy: "Копировать код",
+    copyFailed: "Не удалось скопировать код",
+    loadFailed: "Не удалось загрузить ваш код доступа",
+    renew: "Новый код",
+    renewTitle: "Заменить код доступа?",
+    renewConfirm: "Текущий код перестанет работать. Заново привяжите виртуальный принтер в слайсере с новым кодом.",
+    renewed: "Новый код доступа готов",
+    hintChecked: "При каждом входе проверяется, можете ли вы отправлять на этот виртуальный принтер и его принтер.",
+    hintCamera: "С этим кодом камера в слайсере не работает; камера в Bambuddy работает.",
+    hintSecret: "Он работает как пароль: не передавайте его.",
+  },
   pendingUploads: {
   pendingUploads: {
     title: "Ожидающие загрузки ({{count}})",
     title: "Ожидающие загрузки ({{count}})",
     noApprovePermission: "Для добавления в файловый менеджер нужны права queue:create и library:upload",
     noApprovePermission: "Для добавления в файловый менеджер нужны права queue:create и library:upload",

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

@@ -6427,6 +6427,7 @@ errors: {
     },
     },
     accessCode: {
     accessCode: {
       title: 'Åtkomstkod',
       title: 'Åtkomstkod',
+      personalHint: 'Med inloggning på kan användare också ange sin personliga åtkomstkod (sidofältet, skrivarikonen): det de skickar blir då deras.',
       isSet: 'Åtkomstkod är inställd',
       isSet: 'Åtkomstkod är inställd',
       notSet: 'Ingen åtkomstkod inställd — krävs för att aktivera',
       notSet: 'Ingen åtkomstkod inställd — krävs för att aktivera',
       placeholder: 'Ange 8-teckens kod',
       placeholder: 'Ange 8-teckens kod',
@@ -7404,6 +7405,22 @@ errors: {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: 'Åtkomstkod för slicern',
+    description: 'Ange den här koden i Bambu Studio eller OrcaSlicer i stället för den virtuella skrivarens åtkomstkod. Det du skickar till den virtuella skrivaren blir då ditt.',
+    show: 'Visa kod',
+    hide: 'Dölj kod',
+    copy: 'Kopiera kod',
+    copyFailed: 'Koden kunde inte kopieras',
+    loadFailed: 'Din åtkomstkod kunde inte läsas in',
+    renew: 'Ny kod',
+    renewTitle: 'Ersätta din åtkomstkod?',
+    renewConfirm: 'Din nuvarande kod slutar fungera. Koppla den virtuella skrivaren i slicern igen med den nya koden.',
+    renewed: 'Ny åtkomstkod klar',
+    hintChecked: 'Varje inloggning kontrollerar att du får skicka till den virtuella skrivaren och dess skrivare.',
+    hintCamera: 'Med den här koden fungerar inte slicerns kameravy; Bambuddys kamera gör det.',
+    hintSecret: 'Den fungerar som ett lösenord: dela den inte.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: 'Väntande uppladdningar ({{count}})',
     title: 'Väntande uppladdningar ({{count}})',
     noApprovePermission: 'Att lägga i filhanteraren kräver behörigheterna queue:create och library:upload',
     noApprovePermission: 'Att lägga i filhanteraren kräver behörigheterna queue:create och library:upload',

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

@@ -6309,6 +6309,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: 'Erişim Kodu',
       title: 'Erişim Kodu',
+      personalHint: 'Kimlik doğrulama açıkken kullanıcılar kişisel erişim kodlarını da girebilir (kenar çubuğu, yazıcı simgesi): gönderdikleri böylece onların olur.',
       isSet: 'Erişim kodu ayarlandı',
       isSet: 'Erişim kodu ayarlandı',
       notSet: 'Erişim kodu ayarlanmadı - etkinleştirmek için gerekli',
       notSet: 'Erişim kodu ayarlanmadı - etkinleştirmek için gerekli',
       placeholder: '8 karakterli kodu girin',
       placeholder: '8 karakterli kodu girin',
@@ -7266,6 +7267,22 @@ export default {
   },
   },
 
 
   // Bekleyen Yüklemeler Paneli
   // Bekleyen Yüklemeler Paneli
+  slicerAccessCode: {
+    title: 'Dilimleyici erişim kodu',
+    description: 'Sanal yazıcının kendi erişim kodu yerine bu kodu Bambu Studio veya OrcaSlicer\'a girin. Sanal yazıcıya gönderdikleriniz böylece sizin olur.',
+    show: 'Kodu göster',
+    hide: 'Kodu gizle',
+    copy: 'Kodu kopyala',
+    copyFailed: 'Kod kopyalanamadı',
+    loadFailed: 'Erişim kodunuz yüklenemedi',
+    renew: 'Yeni kod',
+    renewTitle: 'Erişim kodunuz değiştirilsin mi?',
+    renewConfirm: 'Mevcut kodunuz artık çalışmaz. Dilimleyicide sanal yazıcıyı yeni kodla yeniden bağlayın.',
+    renewed: 'Yeni erişim kodu hazır',
+    hintChecked: 'Her girişte o sanal yazıcıya ve yazıcısına gönderme izniniz olup olmadığı kontrol edilir.',
+    hintCamera: 'Bu kodla dilimleyicinin kamera görünümü çalışmaz; Bambuddy\'nin kamerası çalışır.',
+    hintSecret: 'Bir parola gibidir: paylaşmayın.',
+  },
   pendingUploads: {
   pendingUploads: {
     title: 'Bekleyen yüklemeler ({{count}})',
     title: 'Bekleyen yüklemeler ({{count}})',
     noApprovePermission: 'Dosya Yöneticisine eklemek için queue:create ve library:upload izinleri gerekir',
     noApprovePermission: 'Dosya Yöneticisine eklemek için queue:create ve library:upload izinleri gerekir',

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

@@ -6386,6 +6386,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: "Код доступу",
       title: "Код доступу",
+      personalHint: "З увімкненою автентифікацією користувачі можуть ввести і свій особистий код доступу (бічна панель, значок принтера): надіслане ними належатиме їм.",
       isSet: "Код доступу встановлено",
       isSet: "Код доступу встановлено",
       notSet: "Немає встановленого коду доступу – потрібно для ввімкнення",
       notSet: "Немає встановленого коду доступу – потрібно для ввімкнення",
       placeholder: "Введіть 8-символьний код",
       placeholder: "Введіть 8-символьний код",
@@ -7363,6 +7364,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: "Код доступу для слайсера",
+    description: "Введіть цей код у Bambu Studio або OrcaSlicer замість коду доступу віртуального принтера. Те, що ви надішлете на віртуальний принтер, буде вашим.",
+    show: "Показати код",
+    hide: "Сховати код",
+    copy: "Копіювати код",
+    copyFailed: "Не вдалося скопіювати код",
+    loadFailed: "Не вдалося завантажити ваш код доступу",
+    renew: "Новий код",
+    renewTitle: "Замінити код доступу?",
+    renewConfirm: "Поточний код перестане працювати. Знову прив'яжіть віртуальний принтер у слайсері з новим кодом.",
+    renewed: "Новий код доступу готовий",
+    hintChecked: "Під час кожного входу перевіряється, чи можете ви надсилати на цей віртуальний принтер і його принтер.",
+    hintCamera: "З цим кодом камера в слайсері не працює; камера в Bambuddy працює.",
+    hintSecret: "Він працює як пароль: не передавайте його.",
+  },
   pendingUploads: {
   pendingUploads: {
     title: "Очікувані завантаження ({{count}})",
     title: "Очікувані завантаження ({{count}})",
     noApprovePermission: "Для додавання до менеджера файлів потрібні дозволи queue:create і library:upload",
     noApprovePermission: "Для додавання до менеджера файлів потрібні дозволи queue:create і library:upload",

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

@@ -6332,6 +6332,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: '访问码',
       title: '访问码',
+      personalHint: '启用身份验证后,用户也可以输入自己的个人访问码(侧边栏的打印机图标):他们发送的内容就属于他们。',
       isSet: '访问码已设置',
       isSet: '访问码已设置',
       notSet: '未设置访问码 - 需要设置才能启用',
       notSet: '未设置访问码 - 需要设置才能启用',
       placeholder: '输入 8 位字符代码',
       placeholder: '输入 8 位字符代码',
@@ -7307,6 +7308,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: '切片软件访问码',
+    description: '在 Bambu Studio 或 OrcaSlicer 中输入此访问码,代替虚拟打印机自身的访问码。这样你发送到虚拟打印机的内容就属于你。',
+    show: '显示访问码',
+    hide: '隐藏访问码',
+    copy: '复制访问码',
+    copyFailed: '无法复制访问码',
+    loadFailed: '无法加载你的访问码',
+    renew: '新访问码',
+    renewTitle: '要更换访问码吗?',
+    renewConfirm: '当前访问码将失效。请在切片软件中用新访问码重新绑定虚拟打印机。',
+    renewed: '新访问码已就绪',
+    hintChecked: '每次登录都会检查你是否可以发送到该虚拟打印机及其打印机。',
+    hintCamera: '使用此访问码时,切片软件的摄像头画面无法使用;Bambuddy 的摄像头可以。',
+    hintSecret: '它相当于密码:请勿分享。',
+  },
   pendingUploads: {
   pendingUploads: {
     title: '待处理上传({{count}})',
     title: '待处理上传({{count}})',
     noApprovePermission: '加入文件管理器需要 queue:create 和 library:upload 权限',
     noApprovePermission: '加入文件管理器需要 queue:create 和 library:upload 权限',

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

@@ -6332,6 +6332,7 @@ export default {
     },
     },
     accessCode: {
     accessCode: {
       title: '存取碼',
       title: '存取碼',
+      personalHint: '啟用身分驗證後,使用者也可以輸入自己的個人存取碼(側邊欄的印表機圖示):他們傳送的內容就屬於他們。',
       isSet: '存取碼已設定',
       isSet: '存取碼已設定',
       notSet: '未設定存取碼 - 需要設定才能啟用',
       notSet: '未設定存取碼 - 需要設定才能啟用',
       placeholder: '輸入 8 位字元程式碼',
       placeholder: '輸入 8 位字元程式碼',
@@ -7307,6 +7308,22 @@ export default {
   },
   },
 
 
   // Pending Uploads Panel
   // Pending Uploads Panel
+  slicerAccessCode: {
+    title: '切片軟體存取碼',
+    description: '在 Bambu Studio 或 OrcaSlicer 中輸入此存取碼,取代虛擬印表機本身的存取碼。這樣你傳送到虛擬印表機的內容就屬於你。',
+    show: '顯示存取碼',
+    hide: '隱藏存取碼',
+    copy: '複製存取碼',
+    copyFailed: '無法複製存取碼',
+    loadFailed: '無法載入你的存取碼',
+    renew: '新存取碼',
+    renewTitle: '要更換存取碼嗎?',
+    renewConfirm: '目前的存取碼將失效。請在切片軟體中用新存取碼重新綁定虛擬印表機。',
+    renewed: '新存取碼已就緒',
+    hintChecked: '每次登入都會檢查你是否可以傳送到該虛擬印表機及其印表機。',
+    hintCamera: '使用此存取碼時,切片軟體的攝影機畫面無法使用;Bambuddy 的攝影機可以。',
+    hintSecret: '它相當於密碼:請勿分享。',
+  },
   pendingUploads: {
   pendingUploads: {
     title: '待處理上傳({{count}})',
     title: '待處理上傳({{count}})',
     noApprovePermission: '加入檔案管理器需要 queue:create 與 library:upload 權限',
     noApprovePermission: '加入檔案管理器需要 queue:create 與 library:upload 權限',

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


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


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


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


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


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


+ 1 - 1
static/assets/Button-KJ9adUYB.js → static/assets/Button-D7wJJlop.js

@@ -1 +1 @@
-import{w as e}from"./client-B6bKL_Bn.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};
+import{w as e}from"./client-Bol9xAus.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

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


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


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


+ 1 - 1
static/assets/Card-C_y3K6sN.js → static/assets/Card-CsobaR0C.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-B6bKL_Bn.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Bol9xAus.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

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


File diff suppressed because it is too large
+ 0 - 0
static/assets/ColumnConfigModal-D-J_stEa.js


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


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


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


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


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


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


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


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


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


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


+ 1 - 1
static/assets/FilamentSwatch-DyaIHELd.js → static/assets/FilamentSwatch-DPXw7nIu.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-B6bKL_Bn.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Bol9xAus.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-DpFs7VbB.js


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


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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-D-KzaynP.js


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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-GFM57w-5.js


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-DSbbbP-S.js


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


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


+ 1 - 1
static/assets/NumberInput-DiEjrvBB.js → static/assets/NumberInput-BuS_39vU.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-B6bKL_Bn.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-Bol9xAus.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};

+ 1 - 1
static/assets/OverlayFrame-2JWsWg1w.js → static/assets/OverlayFrame-taEGRB2T.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-B6bKL_Bn.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-Bol9xAus.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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