Browse Source

Merge printer-scoped access (#1727) and the queue review gate (#1620) into dev

Groups can be limited to printers and whole locations, managed on the new
Printer access page, and jobs can wait for staff review before they print.

Merging onto the current dev:
- The Printer Locations page keeps to the same access rules: a rename keeps
  its groups' access, deleting a granted location or moving printers into or
  out of one needs an admin and drops the stale grant, and a user limited to
  some printers sees and changes only their own locations.
- The overlay logo checks its token without a printer, which the stricter
  overlay check now needs and the logo route doesn't have.
maziggy 2 days ago
parent
commit
d336bb2ad2
97 changed files with 5753 additions and 433 deletions
  1. 0 0
      CHANGELOG.md
  2. 3 3
      backend/app/api/routes/ams_history.py
  3. 9 0
      backend/app/api/routes/api_keys.py
  4. 136 50
      backend/app/api/routes/archives.py
  5. 13 2
      backend/app/api/routes/auth.py
  6. 25 15
      backend/app/api/routes/camera.py
  7. 8 3
      backend/app/api/routes/camwall.py
  8. 13 7
      backend/app/api/routes/firmware.py
  9. 138 30
      backend/app/api/routes/groups.py
  10. 2 2
      backend/app/api/routes/ha_sensors.py
  11. 18 2
      backend/app/api/routes/inventory.py
  12. 8 8
      backend/app/api/routes/kprofiles.py
  13. 11 0
      backend/app/api/routes/library.py
  14. 25 9
      backend/app/api/routes/maintenance.py
  15. 7 1
      backend/app/api/routes/obico.py
  16. 2 2
      backend/app/api/routes/overlay_branding.py
  17. 84 26
      backend/app/api/routes/pipeline_runs.py
  18. 20 4
      backend/app/api/routes/print_log.py
  19. 107 12
      backend/app/api/routes/print_queue.py
  20. 122 14
      backend/app/api/routes/printer_locations.py
  21. 3 3
      backend/app/api/routes/printer_sensor_history.py
  22. 93 62
      backend/app/api/routes/printers.py
  23. 9 1
      backend/app/api/routes/projects.py
  24. 9 2
      backend/app/api/routes/scheduled_dryings.py
  25. 27 10
      backend/app/api/routes/smart_plugs.py
  26. 13 3
      backend/app/api/routes/spoolman.py
  27. 18 5
      backend/app/api/routes/spoolman_inventory.py
  28. 22 0
      backend/app/api/routes/users.py
  29. 32 10
      backend/app/api/routes/webhook.py
  30. 28 5
      backend/app/api/routes/websocket.py
  31. 311 65
      backend/app/core/auth.py
  32. 49 0
      backend/app/core/database.py
  33. 6 0
      backend/app/core/permissions.py
  34. 199 0
      backend/app/core/printer_scope.py
  35. 68 1
      backend/app/core/websocket.py
  36. 3 1
      backend/app/models/__init__.py
  37. 4 0
      backend/app/models/auth_ephemeral.py
  38. 28 0
      backend/app/models/group.py
  39. 11 0
      backend/app/schemas/group.py
  40. 6 0
      backend/app/services/archive.py
  41. 6 0
      backend/app/services/export.py
  42. 5 0
      backend/app/services/failure_analysis.py
  43. 4 0
      backend/app/services/oidc_group_sync.py
  44. 41 2
      backend/app/services/print_scheduler.py
  45. 3 1
      backend/tests/integration/test_archives_api.py
  46. 5 3
      backend/tests/integration/test_camwall_api.py
  47. 6 4
      backend/tests/integration/test_long_lived_tokens_api.py
  48. 2 1
      backend/tests/integration/test_obico_api.py
  49. 5 4
      backend/tests/integration/test_overlay_status_api.py
  50. 859 0
      backend/tests/integration/test_printer_scope_1727.py
  51. 2 1
      backend/tests/integration/test_printers_api.py
  52. 368 0
      backend/tests/integration/test_queue_review_1620.py
  53. 2 1
      backend/tests/integration/test_webhook_printer_status.py
  54. 7 3
      backend/tests/unit/test_archive_filtering.py
  55. 128 0
      backend/tests/unit/test_printer_scope.py
  56. 3 2
      backend/tests/unit/test_route_auth_coverage.py
  57. 3 1
      backend/tests/unit/test_timelapse_scan_2704.py
  58. 7 1
      backend/tests/unit/test_ws_broadcast_to_user.py
  59. 131 0
      frontend/src/__tests__/components/EditPrinterLocationAccess.test.tsx
  60. 86 1
      frontend/src/__tests__/components/PrintModal.test.tsx
  61. 212 0
      frontend/src/__tests__/components/PrinterAccessSettings.test.tsx
  62. 101 0
      frontend/src/__tests__/pages/GroupEditPage.test.tsx
  63. 39 1
      frontend/src/__tests__/pages/QueuePage.test.tsx
  64. 44 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  65. 13 2
      frontend/src/api/client.ts
  66. 10 1
      frontend/src/components/PrintModal/ScheduleOptions.tsx
  67. 18 5
      frontend/src/components/PrintModal/index.tsx
  68. 2 0
      frontend/src/components/PrintModal/types.ts
  69. 787 0
      frontend/src/components/PrinterAccessSettings.tsx
  70. 67 0
      frontend/src/i18n/locales/de.ts
  71. 67 0
      frontend/src/i18n/locales/en.ts
  72. 67 0
      frontend/src/i18n/locales/es.ts
  73. 67 0
      frontend/src/i18n/locales/fr.ts
  74. 67 0
      frontend/src/i18n/locales/it.ts
  75. 67 0
      frontend/src/i18n/locales/ja.ts
  76. 69 1
      frontend/src/i18n/locales/ko.ts
  77. 67 0
      frontend/src/i18n/locales/nl.ts
  78. 67 0
      frontend/src/i18n/locales/pt-BR.ts
  79. 68 0
      frontend/src/i18n/locales/ru.ts
  80. 67 0
      frontend/src/i18n/locales/sv.ts
  81. 67 0
      frontend/src/i18n/locales/tr.ts
  82. 67 0
      frontend/src/i18n/locales/uk.ts
  83. 67 0
      frontend/src/i18n/locales/zh-CN.ts
  84. 67 0
      frontend/src/i18n/locales/zh-TW.ts
  85. 1 1
      frontend/src/lib/settingsSearch.ts
  86. 44 8
      frontend/src/pages/GroupEditPage.tsx
  87. 42 2
      frontend/src/pages/PrintersPage.tsx
  88. 12 3
      frontend/src/pages/QueuePage.tsx
  89. 54 21
      frontend/src/pages/SettingsPage.tsx
  90. 0 0
      static/assets/ImagePreviewModal-Brmzfcnz.js
  91. 0 1
      static/assets/PdfPreviewModal-Dz7p_Lnx.js
  92. 0 0
      static/assets/SpreadsheetPreviewModal-CMRIA3iR.js
  93. 0 1
      static/assets/index-C5OR618T.css
  94. 0 1
      static/assets/index-CSbs3wvg.js
  95. 1 0
      static/assets/index-DKHAZk9g.css
  96. 0 0
      static/assets/pdf-BC_iAnuJ.js
  97. 2 2
      static/index.html

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 3 - 3
backend/app/api/routes/ams_history.py

@@ -7,7 +7,7 @@ from pydantic import BaseModel
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.ams_history import AMSSensorHistory
@@ -41,7 +41,7 @@ async def get_ams_history(
     ams_id: int,
     hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
     """Get AMS sensor history for a specific printer and AMS unit."""
     since = datetime.now(timezone.utc) - timedelta(hours=hours)
@@ -109,7 +109,7 @@ async def delete_old_history(
     printer_id: int,
     days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.AMS_HISTORY_READ),
 ):
     """Delete old AMS history data for a printer."""
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)

+ 9 - 0
backend/app/api/routes/api_keys.py

@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
 from backend.app.models.user import User
 from backend.app.schemas.api_key import (
@@ -175,6 +176,11 @@ async def update_api_key(
 
     await db.flush()
     await db.refresh(api_key)
+    if data.printer_ids is not None or data.enabled is not None or data.expires_at is not None:
+        # Sockets opened with this key may now see fewer printers, or none
+        # (#1727). Committed first so the refresh reads the new row.
+        await db.commit()
+        await ws_manager.refresh_printer_scopes()
 
     return api_key
 
@@ -193,5 +199,8 @@ async def delete_api_key(
         raise HTTPException(status_code=404, detail="API key not found")
 
     await db.delete(api_key)
+    # Sockets opened with this key lose their printers (#1727)
+    await db.commit()
+    await ws_manager.refresh_printer_scopes()
 
     return {"message": "API key deleted"}

+ 136 - 50
backend/app/api/routes/archives.py

@@ -17,9 +17,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core import database
 from backend.app.core.auth import (
+    MediaOrRequestPrinterScope,
+    RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
-    check_printer_access,
-    current_api_key_if_present,
     probe_permissions_if_auth_enabled,
     require_media_token_ownership,
     require_ownership_permission,
@@ -27,7 +27,7 @@ from backend.app.core.auth import (
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
-from backend.app.models.api_key import APIKey
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
@@ -178,6 +178,7 @@ def _ensure_archive_visible(
     archive: PrintArchive | None,
     user: User | None,
     can_read_all: bool,
+    printer_scope: PrinterScope,
 ) -> PrintArchive:
     """Per-archive visibility gate for ownership-scoped reads (#1726-adjacent).
 
@@ -195,9 +196,15 @@ def _ensure_archive_visible(
         nonexistent id. Pre-GHSA fix the caller saw 200 here (the PoC vector).
       - Ownerless rows (``created_by_id is None``) require ALL — fail-closed
         per ``feedback_no_fail_open_in_auth``.
+      - An archive whose printer is outside ``printer_scope`` → 404 (#1727).
     """
     if not archive or archive.deleted_at is not None:
         raise HTTPException(404, "Archive not found")
+    # An archive from a printer the caller can't see is as missing as the
+    # printer itself (#1727). Archives with no printer stay governed by
+    # ownership alone.
+    if not printer_scope.allows(archive.printer_id):
+        raise HTTPException(404, "Archive not found")
     if can_read_all:
         return archive
     # Auth enabled, caller has _OWN only.
@@ -422,6 +429,7 @@ async def list_archives(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """List archived prints."""
     user, can_read_all = auth_result
@@ -435,6 +443,7 @@ async def list_archives(
         limit=limit,
         offset=offset,
         visible_to_user_id=visible_to_user_id,
+        printer_scope=printer_scope,
     )
 
     # Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries)
@@ -652,6 +661,7 @@ async def list_archives_slim(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Per-event listing for stats/dashboard widgets.
 
@@ -682,6 +692,8 @@ async def list_archives_slim(
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
         filters.append(PrintLogEntry.created_at <= dt_to)
     _apply_run_user_filter(filters, created_by_id)
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        filters.append(clause)
 
     query = (
         select(
@@ -764,6 +776,7 @@ async def search_archives(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Full-text search across archives.
 
@@ -839,6 +852,8 @@ async def search_archives(
             query = query.where(PrintArchive.status == status)
         if own_only:
             query = query.where(PrintArchive.created_by_id == user.id)
+        if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+            query = query.where(clause)
 
         query = query.limit(limit).offset(offset)
         result = await db.execute(query)
@@ -859,6 +874,8 @@ async def search_archives(
     )
     if own_only:
         query = query.where(PrintArchive.created_by_id == user.id)
+    if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        query = query.where(clause)
 
     # Apply additional filters
     if printer_id:
@@ -938,6 +955,7 @@ async def analyze_failures(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Analyze failure patterns across prints.
 
@@ -964,6 +982,7 @@ async def analyze_failures(
         printer_id=printer_id,
         project_id=project_id,
         created_by_id=created_by_id,
+        printer_scope=printer_scope,
     )
 
 
@@ -977,6 +996,7 @@ async def compare_archives(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Compare multiple archives side by side.
 
@@ -1015,6 +1035,12 @@ async def compare_archives(
             if row is None or row.deleted_at is not None or row.created_by_id != user.id:
                 raise HTTPException(404, "Archive not found")
 
+    # Every compared archive must come from a printer the caller can see (#1727)
+    if not printer_scope.is_unrestricted:
+        printer_ids = await db.execute(select(PrintArchive.printer_id).where(PrintArchive.id.in_(ids)))
+        if not all(printer_scope.allows(pid) for (pid,) in printer_ids.all()):
+            raise HTTPException(404, "Archive not found")
+
     service = ArchiveComparisonService(db)
     try:
         return await service.compare_archives(ids)
@@ -1033,6 +1059,7 @@ async def export_archives(
     date_to: str | None = Query(None, description="End date (ISO format)"),
     search: str | None = None,
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
             Permission.ARCHIVES_READ_ALL,
@@ -1087,6 +1114,7 @@ async def export_archives(
             date_to=date_to_dt,
             search=search,
             visible_to_user_id=visible_to_user_id,
+            printer_scope=printer_scope,
         )
     except ImportError as e:
         raise HTTPException(500, str(e))
@@ -1107,6 +1135,7 @@ async def export_stats(
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Export statistics summary to CSV or Excel format."""
     _validate_user_filter_permission(current_user, created_by_id)
@@ -1126,6 +1155,7 @@ async def export_stats(
             printer_id=printer_id,
             project_id=project_id,
             created_by_id=created_by_id,
+            printer_scope=printer_scope,
         )
     except ImportError as e:
         raise HTTPException(500, str(e))
@@ -1144,6 +1174,7 @@ async def get_archive_stats(
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get statistics across all archives.
 
@@ -1165,6 +1196,9 @@ async def get_archive_stats(
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
         base_conditions.append(PrintLogEntry.created_at <= dt_to)
     _apply_run_user_filter(base_conditions, created_by_id)
+    # Only prints on printers the caller may see (#1727)
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        base_conditions.append(clause)
 
     # Total counts (one row per print event).
     total_result = await db.execute(select(func.count(PrintLogEntry.id)).where(*base_conditions))
@@ -1632,11 +1666,12 @@ async def get_archive(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get a specific archive."""
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     # Find duplicates
     makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
@@ -1660,6 +1695,7 @@ async def get_archive_delete_impact(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Pre-flight for the delete-confirm modal (#1734).
 
@@ -1672,7 +1708,7 @@ async def get_archive_delete_impact(
     """
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
     from backend.app.services.archive import _count_related_queue_items
 
     total, printing = await _count_related_queue_items(db, archive.id)
@@ -1689,6 +1725,7 @@ async def list_archive_runs(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """List PrintLogEntry rows for this archive — one per print event.
 
@@ -1698,7 +1735,7 @@ async def list_archive_runs(
     from backend.app.schemas.print_log import PrintLogEntrySchema
 
     user, can_read_all = auth_result
-    _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all)
+    _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all, printer_scope)
 
     rows = await db.execute(
         select(PrintLogEntry)
@@ -1721,6 +1758,7 @@ async def find_similar_archives(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Find archives with similar settings for comparison.
 
@@ -1732,7 +1770,7 @@ async def find_similar_archives(
     from backend.app.services.archive_comparison import ArchiveComparisonService
 
     user, can_read_all = auth_result
-    _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all)
+    _ensure_archive_visible(await db.get(PrintArchive, archive_id), user, can_read_all, printer_scope)
 
     service = ArchiveComparisonService(db)
     try:
@@ -1752,6 +1790,7 @@ async def update_archive(
             Permission.ARCHIVES_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Update archive metadata (tags, notes, cost, filament grams, is_favorite, project_id)."""
     from sqlalchemy.orm import selectinload
@@ -1764,7 +1803,7 @@ async def update_archive(
         .where(PrintArchive.id == archive_id)
     )
     archive = result.scalar_one_or_none()
-    if not archive:
+    if not archive or not printer_scope.allows(archive.printer_id):
         raise HTTPException(404, "Archive not found")
 
     # Ownership check
@@ -1863,11 +1902,12 @@ async def toggle_favorite(
             Permission.ARCHIVES_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Toggle favorite status for an archive."""
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
     archive.is_favorite = not archive.is_favorite
     await db.commit()
@@ -1899,6 +1939,7 @@ async def rescan_archive(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Rescan the 3MF file and update metadata."""
     from backend.app.api.routes.settings import get_setting
@@ -1906,7 +1947,7 @@ async def rescan_archive(
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
-    if not archive:
+    if not archive or not printer_scope.allows(archive.printer_id):
         raise HTTPException(404, "Archive not found")
 
     file_path = settings.base_dir / archive.file_path
@@ -2134,11 +2175,12 @@ async def get_archive_duplicates(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get duplicates for a specific archive."""
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     makerworld_id = archive.extra_data.get("makerworld_model_id") if archive.extra_data else None
     duplicates = await service.find_duplicates(
@@ -2198,6 +2240,7 @@ async def delete_archive(
             Permission.ARCHIVES_DELETE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Delete an archive (soft by default; ``?purge_stats=true`` to hard-delete).
 
@@ -2212,7 +2255,7 @@ async def delete_archive(
     # Get archive first to check ownership
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
-    if not archive:
+    if not archive or not printer_scope.allows(archive.printer_id):
         raise HTTPException(404, "Archive not found")
 
     # Ownership check
@@ -2267,11 +2310,12 @@ async def download_archive(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Download the 3MF file."""
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -2299,11 +2343,12 @@ async def download_archive_with_filename(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Download the 3MF file with filename in URL."""
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -2326,6 +2371,7 @@ async def create_archive_slicer_token(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Create a short-lived download token for opening files in slicer applications.
 
@@ -2336,7 +2382,7 @@ async def create_archive_slicer_token(
 
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     token = await create_slicer_download_token("archive", archive_id)
     return {"token": token}
@@ -2388,6 +2434,7 @@ async def get_thumbnail(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get the thumbnail image.
 
@@ -2396,7 +2443,7 @@ async def get_thumbnail(
     """
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
     if not archive.thumbnail_path:
         raise HTTPException(404, "Thumbnail not found")
 
@@ -2427,7 +2474,7 @@ async def get_archive_printer_media(
         )
     ),
     can_list_printer_files: bool = Depends(probe_permissions_if_auth_enabled(Permission.PRINTERS_FILES)),
-    api_key: APIKey | None = Depends(current_api_key_if_present),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Find downloadable timelapse and `/ipcam` files for one print.
 
@@ -2439,7 +2486,9 @@ async def get_archive_printer_media(
 
     user, can_read_all = auth_result
     async with database.async_session() as db:
-        archive = _ensure_archive_visible(await ArchiveService(db).get_archive(archive_id), user, can_read_all)
+        archive = _ensure_archive_visible(
+            await ArchiveService(db).get_archive(archive_id), user, can_read_all, printer_scope
+        )
         printer = None
         claimed_timelapse_stems: set[str] = set()
         if archive.printer_id is not None:
@@ -2469,11 +2518,9 @@ async def get_archive_printer_media(
         response["warnings"].append("printer_files_forbidden")
         return response
 
-    if printer is None:
+    if printer is None or not printer_scope.allows(printer.id):
         response["warnings"].append("printer_missing")
         return response
-    if api_key is not None:
-        check_printer_access(api_key, printer.id)
 
     if ftps_handshake_blocked(printer.ip_address):
         if local_timelapse is None:
@@ -2568,6 +2615,7 @@ async def create_archive_media_download_token(
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(Permission.ARCHIVES_READ_ALL, Permission.ARCHIVES_READ_OWN)
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Mint a single-use token bound to an archive's attached timelapse."""
 
@@ -2575,7 +2623,9 @@ async def create_archive_media_download_token(
 
     user, can_read_all = auth_result
     async with database.async_session() as db:
-        archive = _ensure_archive_visible(await ArchiveService(db).get_archive(archive_id), user, can_read_all)
+        archive = _ensure_archive_visible(
+            await ArchiveService(db).get_archive(archive_id), user, can_read_all, printer_scope
+        )
     if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
     timelapse_path = settings.base_dir / archive.timelapse_path
@@ -2624,6 +2674,7 @@ async def get_timelapse(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get the timelapse video.
 
@@ -2632,7 +2683,7 @@ async def get_timelapse(
     """
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
     if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
 
@@ -2669,11 +2720,12 @@ async def delete_timelapse(
             Permission.ARCHIVES_DELETE_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Remove the timelapse video from an archive."""
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
     if not archive.timelapse_path:
         raise HTTPException(404, "No timelapse attached to this archive")
@@ -2694,6 +2746,7 @@ async def delete_timelapse(
 async def scan_timelapse(
     archive_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Scan printer for timelapse matching this archive and attach it."""
     from backend.app.core.database import async_session
@@ -2725,6 +2778,7 @@ async def scan_timelapse(
         if not archive.printer_id:
             raise HTTPException(400, "Archive has no associated printer")
 
+        printer_scope.ensure(archive.printer_id)
         result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
         printer = result.scalar_one_or_none()
         if not printer:
@@ -2955,6 +3009,7 @@ async def select_timelapse(
     archive_id: int,
     filename: str = Query(..., description="Timelapse filename to attach"),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Manually select a timelapse from the printer to attach."""
     from backend.app.core.database import async_session
@@ -2979,6 +3034,7 @@ async def select_timelapse(
         if not archive.printer_id:
             raise HTTPException(400, "Archive has no associated printer")
 
+        printer_scope.ensure(archive.printer_id)
         result = await db.execute(select(Printer).where(Printer.id == archive.printer_id))
         printer = result.scalar_one_or_none()
         if not printer:
@@ -3078,11 +3134,12 @@ async def upload_timelapse(
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Manually upload a timelapse video to an archive."""
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
-    if not archive:
+    if not archive or not printer_scope.allows(archive.printer_id):
         raise HTTPException(404, "Archive not found")
 
     if not file.filename or not file.filename.endswith((".mp4", ".avi", ".mkv")):
@@ -3108,6 +3165,7 @@ async def get_timelapse_info(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get timelapse video metadata for editor."""
     from backend.app.schemas.timelapse import TimelapseInfoResponse
@@ -3115,7 +3173,7 @@ async def get_timelapse_info(
 
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
     if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
 
@@ -3144,6 +3202,7 @@ async def get_timelapse_thumbnails(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Generate timeline thumbnail frames for visual scrubbing."""
     import base64
@@ -3153,7 +3212,7 @@ async def get_timelapse_thumbnails(
 
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
     if not archive.timelapse_path:
         raise HTTPException(404, "Timelapse not found")
 
@@ -3185,6 +3244,7 @@ async def process_timelapse(
     audio: UploadFile = File(None),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Process timelapse with trim, speed, and optional audio overlay."""
     import shutil
@@ -3202,7 +3262,7 @@ async def process_timelapse(
 
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
-    if not archive or not archive.timelapse_path:
+    if not archive or not archive.timelapse_path or not printer_scope.allows(archive.printer_id):
         raise HTTPException(404, "Timelapse not found")
 
     timelapse_path = settings.base_dir / archive.timelapse_path
@@ -3300,11 +3360,12 @@ async def upload_photo(
             Permission.ARCHIVES_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Upload a photo of the printed result."""
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
     if not file.filename or not file.filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")):
         raise HTTPException(400, "File must be an image (.jpg, .jpeg, .png, .webp)")
@@ -3347,6 +3408,7 @@ async def get_photo(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get a specific photo.
 
@@ -3355,7 +3417,7 @@ async def get_photo(
     """
     user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
 
     # Membership check first — UUID-generated names on upload mean any URL
     # filename that doesn't appear here is by definition not a real photo.
@@ -3398,11 +3460,12 @@ async def delete_photo(
             Permission.ARCHIVES_DELETE_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Delete a photo."""
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
     if not archive.photos or filename not in archive.photos:
         raise HTTPException(404, "Photo not found")
@@ -3682,6 +3745,7 @@ async def get_qrcode(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Generate a QR code that links to this archive.
 
@@ -3696,7 +3760,7 @@ async def get_qrcode(
         raise HTTPException(500, "QR code generation not available - qrcode package not installed")
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
 
     # Build URL to archive download
     base_url = str(request.base_url).rstrip("/")
@@ -3744,13 +3808,14 @@ async def get_archive_capabilities(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Check what viewing capabilities are available for this 3MF file."""
     import defusedxml.ElementTree as ET
 
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -3971,6 +4036,7 @@ async def get_gcode(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Extract and return G-code from the 3MF file.
 
@@ -3982,7 +4048,7 @@ async def get_gcode(
     """
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4028,6 +4094,7 @@ async def get_plate_preview(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get the plate preview image from the 3MF file.
 
@@ -4039,7 +4106,7 @@ async def get_plate_preview(
     """
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4108,6 +4175,7 @@ async def upload_archive(
     ),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Manually upload a 3MF file to archive.
 
@@ -4118,6 +4186,7 @@ async def upload_archive(
     is per-request, because the caller is an API client that knows whether the
     filename it sent is the meaningful one (#2609).
     """
+    printer_scope.ensure(printer_id)
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
 
@@ -4169,12 +4238,14 @@ async def upload_archives_bulk(
     ),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Bulk upload multiple 3MF files to archive.
 
     prefer_filename_for_name applies to every file in the batch. See
     upload_archive for the flag's lineage.
     """
+    printer_scope.ensure(printer_id)
     from backend.app.api.routes.library import validate_print_file_upload
 
     results = []
@@ -4247,6 +4318,7 @@ async def get_archive_plates(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get available plates from a multi-plate 3MF archive.
 
@@ -4259,7 +4331,7 @@ async def get_archive_plates(
 
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4566,6 +4638,7 @@ async def get_plate_thumbnail(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get the thumbnail image for a specific plate.
 
@@ -4574,7 +4647,7 @@ async def get_plate_thumbnail(
     """
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4652,6 +4725,7 @@ async def get_filament_requirements(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get filament requirements from the archived 3MF file.
 
@@ -4666,7 +4740,7 @@ async def get_filament_requirements(
 
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4817,6 +4891,7 @@ async def slice_archive(
     request: SliceRequest,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.LIBRARY_UPLOAD),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Enqueue a slice job for an archive's source. Returns 202 + job_id;
     the slice runs in the background, the caller polls `GET /slice-jobs/{id}`.
@@ -4838,7 +4913,7 @@ async def slice_archive(
     # though GET on that id returned 404. API-key / auth-disabled callers
     # (current_user is None) keep can_read_all=True — no per-row identity.
     can_read_all = current_user is None or current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
-    archive = _ensure_archive_visible(archive, current_user, can_read_all)
+    archive = _ensure_archive_visible(archive, current_user, can_read_all, printer_scope)
 
     src_relative = archive.source_3mf_path or archive.file_path
     if not src_relative:
@@ -4958,6 +5033,7 @@ async def get_project_page(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get the project page data from the 3MF file."""
     from backend.app.schemas.archive import ProjectPageResponse
@@ -4965,7 +5041,7 @@ async def get_project_page(
 
     user, can_read_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -4988,13 +5064,14 @@ async def update_project_page(
             Permission.ARCHIVES_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Update project page metadata in the 3MF file."""
     from backend.app.services.archive import ProjectPageParser
 
     user, can_modify_all = auth_result
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_modify_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_modify_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -5022,6 +5099,7 @@ async def get_project_image(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get an image from the 3MF project page.
 
@@ -5032,7 +5110,7 @@ async def get_project_image(
     from backend.app.services.archive import ProjectPageParser
 
     service = ArchiveService(db)
-    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all)
+    archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all, printer_scope)
 
     file_path = settings.base_dir / archive.file_path
     if not file_path.is_file():
@@ -5115,11 +5193,12 @@ async def upload_source_3mf(
             Permission.ARCHIVES_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Upload the original source 3MF project file for an archive."""
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
@@ -5166,11 +5245,12 @@ async def download_source_3mf(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Download the source 3MF project file."""
     user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
 
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
@@ -5200,11 +5280,12 @@ async def download_source_3mf_for_slicer(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Download source 3MF with filename in URL."""
     user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
 
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
@@ -5230,13 +5311,14 @@ async def create_source_slicer_token(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Create a short-lived download token for opening source 3MF in slicer."""
     from backend.app.core.auth import create_slicer_download_token
 
     user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
 
@@ -5381,11 +5463,12 @@ async def delete_source_3mf(
             Permission.ARCHIVES_DELETE_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Delete the source 3MF project file from an archive."""
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
     if not archive.source_3mf_path:
         raise HTTPException(404, "No source 3MF attached to this archive")
@@ -5418,11 +5501,12 @@ async def upload_f3d(
             Permission.ARCHIVES_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Upload a Fusion 360 design file for an archive."""
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
     if not file.filename or not file.filename.endswith(".f3d"):
         raise HTTPException(400, "File must be a .f3d file")
@@ -5469,11 +5553,12 @@ async def download_f3d(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Download the Fusion 360 design file."""
     user, can_read_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all, printer_scope)
 
     if not archive.f3d_path:
         raise HTTPException(404, "No F3D file attached to this archive")
@@ -5502,11 +5587,12 @@ async def delete_f3d(
             Permission.ARCHIVES_DELETE_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Delete the Fusion 360 design file from an archive."""
     user, can_modify_all = auth_result
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
-    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all)
+    archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_modify_all, printer_scope)
 
     if not archive.f3d_path:
         raise HTTPException(404, "No F3D file attached to this archive")

+ 13 - 2
backend/app/api/routes/auth.py

@@ -27,6 +27,7 @@ from backend.app.core.auth import (
     create_access_token,
     create_media_token,
     create_websocket_token,
+    current_api_key_if_present,
     get_current_active_user,
     get_password_hash,
     get_user_by_email,
@@ -40,6 +41,7 @@ from backend.app.core.auth import (
 )
 from backend.app.core.database import async_session, get_db
 from backend.app.core.oidc_env import env_bool
+from backend.app.models.api_key import APIKey
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
 from backend.app.models.settings import Settings
@@ -644,6 +646,7 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
 @router.post("/ws-token")
 async def mint_websocket_token(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.WEBSOCKET_CONNECT),
+    api_key: APIKey | None = Depends(current_api_key_if_present),
 ):
     """Mint a short-lived token for ``/api/v1/ws`` connections (GHSA-r2qv follow-up).
 
@@ -661,7 +664,9 @@ async def mint_websocket_token(
     passes via the standard allowlist (``can_read_status`` covers it).
     """
     username = current_user.username if current_user is not None else None
-    return {"token": await create_websocket_token(username)}
+    # The socket only carries the printers its minter may see (#1727)
+    api_key_id = api_key.id if api_key is not None else None
+    return {"token": await create_websocket_token(username, api_key_id)}
 
 
 @router.post("/media-token")
@@ -1466,13 +1471,19 @@ async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config)
 
     current_group_ids = {g.id for g in user.groups}
     new_group_ids = {g.id for g in new_groups}
-    if current_group_ids != new_group_ids:
+    groups_changed = current_group_ids != new_group_ids
+    if groups_changed:
         user.groups = new_groups
         changed = True
 
     if changed:
         await db.commit()
         logger.info("Synced LDAP user attributes: %s", user.username)
+        if groups_changed:
+            # The user's open dashboards may now see other printers (#1727)
+            from backend.app.core.websocket import ws_manager
+
+            await ws_manager.refresh_printer_scopes()
 
 
 @router.post("/ldap/test")

+ 25 - 15
backend/app/api/routes/camera.py

@@ -20,10 +20,13 @@ from backend.app.core import database
 from backend.app.core.auth import (
     RequireCameraStreamTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
     create_camera_stream_token,
+    current_api_key_if_present,
 )
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.models.api_key import APIKey
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.services.camera import (
@@ -932,14 +935,21 @@ async def generate_rtsp_mjpeg_stream(
 
 @router.post("/camera/stream-token")
 async def create_stream_token(
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    api_key: APIKey | None = Depends(current_api_key_if_present),
 ):
     """Create a reusable token for camera stream/snapshot access.
 
     Returns a token valid for 60 minutes that can be appended as ?token=xxx
-    to camera stream/snapshot URLs loaded via <img> tags.
+    to camera stream/snapshot URLs loaded via <img> tags. The token opens only
+    the printers its minter may see (#1727).
     """
-    return {"token": await create_camera_stream_token()}
+    return {
+        "token": await create_camera_stream_token(
+            username=user.username if user is not None else None,
+            api_key_id=api_key.id if api_key is not None else None,
+        )
+    }
 
 
 @router.get("/{printer_id}/camera/stream")
@@ -1185,7 +1195,7 @@ async def camera_stream(
 @router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])
 async def stop_camera_stream(
     printer_id: int,
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Stop active camera streams for a printer.
 
@@ -1382,7 +1392,7 @@ async def _snapshot_response(printer_id: int, printer: Printer) -> Response:
 async def test_camera(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Test camera connection for a printer.
 
@@ -1403,7 +1413,7 @@ async def test_camera(
 async def diagnose_camera_route(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Run staged diagnostics for a printer's camera path.
 
@@ -1437,7 +1447,7 @@ async def diagnose_camera_route(
 @router.get("/{printer_id}/camera/status")
 async def camera_status(
     printer_id: int,
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Get the status of an active camera stream.
 
@@ -1505,7 +1515,7 @@ async def test_external_camera(
     url: str,
     camera_type: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Test external camera connection.
 
@@ -1532,7 +1542,7 @@ async def check_plate_empty(
     use_external: bool | None = None,
     include_debug_image: bool = False,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Check if the build plate is empty using camera vision.
 
@@ -1650,7 +1660,7 @@ async def calibrate_plate_detection(
     label: str | None = None,
     use_external: bool | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Calibrate plate detection by capturing a reference image of the empty plate.
 
@@ -1723,7 +1733,7 @@ async def delete_plate_calibration(
     printer_id: int,
     plate_type: str | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Delete the plate detection calibration for a printer and plate type.
 
@@ -1764,7 +1774,7 @@ async def get_plate_detection_status(
     printer_id: int,
     plate_type: str | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Check plate detection status for a printer and plate type.
 
@@ -1808,7 +1818,7 @@ async def get_plate_detection_status(
 async def get_plate_references(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Get all calibration references for a printer with metadata.
 
@@ -1873,7 +1883,7 @@ async def update_reference_label(
     index: int,
     label: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Update the label for a calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
@@ -1898,7 +1908,7 @@ async def delete_reference(
     printer_id: int,
     index: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Delete a specific calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available

+ 8 - 3
backend/app/api/routes/camwall.py

@@ -24,6 +24,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequireCamWallTokenIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.services.printer_manager import printer_manager
 
@@ -34,10 +35,11 @@ router = APIRouter(prefix="/camwall", tags=["camwall"])
 
 @router.get("/printers")
 async def list_camwall_printers(
-    _: None = RequireCamWallTokenIfAuthEnabled,
+    printer_scope: PrinterScope = RequireCamWallTokenIfAuthEnabled,
     db: AsyncSession = Depends(get_db),
 ) -> list[dict]:
-    """Every printer plus the handful of status fields a Cam Wall tile draws.
+    """Every printer the token's owner may see (#1727), plus the handful of
+    status fields a Cam Wall tile draws.
 
     One call for the whole wall rather than one per printer: a kiosk polls this
     on a fixed interval with no WebSocket to invalidate it, and N+1 requests
@@ -46,7 +48,10 @@ async def list_camwall_printers(
     Ordered by name so tile positions stay put across polls — a wall that
     reshuffles itself is unusable to watch.
     """
-    result = await db.execute(select(Printer).order_by(Printer.name))
+    query = select(Printer).order_by(Printer.name)
+    if (clause := printer_scope.where_strict(Printer.id)) is not None:
+        query = query.where(clause)
+    result = await db.execute(query)
     printers = list(result.scalars().all())
 
     payload: list[dict] = []

+ 13 - 7
backend/app/api/routes/firmware.py

@@ -12,9 +12,14 @@ from pydantic import BaseModel, Field
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
@@ -106,9 +111,10 @@ def _not_checked(printer: Printer) -> FirmwareUpdateInfo:
 async def check_firmware_updates(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """
-    Check for firmware updates for all connected printers.
+    Check for firmware updates for the connected printers the caller may see.
 
     Compares each printer's current firmware version against the latest
     available version from Bambu Lab's official firmware download page.
@@ -120,7 +126,7 @@ async def check_firmware_updates(
 
     # Get all printers from database
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
     if not await _checks_enabled(db):
         return FirmwareUpdatesResponse(updates=[_not_checked(p) for p in printers], updates_available=0)
@@ -163,7 +169,7 @@ async def check_firmware_updates(
 async def check_printer_firmware(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
     Check for firmware update for a specific printer.
@@ -267,7 +273,7 @@ async def prepare_firmware_upload(
     printer_id: int,
     version: str | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
     Check prerequisites for uploading firmware to a printer.
@@ -290,7 +296,7 @@ async def start_firmware_upload(
     printer_id: int,
     version: str | None = None,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
 ):
     """
     Start uploading firmware to a printer's SD card.
@@ -340,7 +346,7 @@ async def start_firmware_upload(
 @router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
 async def get_firmware_upload_status(
     printer_id: int,
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
     Get the current status of a firmware upload operation.

+ 138 - 30
backend/app/api/routes/groups.py

@@ -1,7 +1,7 @@
 """Group management API routes."""
 
 from fastapi import APIRouter, Depends, HTTPException, status
-from sqlalchemy import select
+from sqlalchemy import delete, insert, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -12,7 +12,10 @@ from backend.app.core.permissions import (
     PERMISSION_CATEGORIES,
     Permission,
 )
-from backend.app.models.group import Group
+from backend.app.core.printer_scope import group_location_names, group_printer_ids
+from backend.app.core.websocket import ws_manager
+from backend.app.models.group import Group, group_locations, group_printers
+from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.schemas.group import (
     GroupCreate,
@@ -34,6 +37,8 @@ router = APIRouter(prefix="/groups", tags=["groups"])
 # box in the group editor has nothing else to go on (#1894).
 _PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = {
     Permission.USERS_READ_SLIM: "List User Names (id + username only)",
+    # "Start Unreviewed Queue" says nothing about what happens without it (#1620)
+    Permission.QUEUE_START_UNREVIEWED: "Print Without Review (else jobs wait for someone to start them)",
 }
 
 
@@ -51,6 +56,108 @@ def _permission_label(perm: Permission) -> str:
     return perm.value
 
 
+async def _printer_ids_by_group(db: AsyncSession) -> dict[int, list[int]]:
+    result = await db.execute(select(group_printers.c.group_id, group_printers.c.printer_id))
+    by_group: dict[int, list[int]] = {}
+    for group_id, printer_id in result.all():
+        by_group.setdefault(group_id, []).append(printer_id)
+    return {gid: sorted(pids) for gid, pids in by_group.items()}
+
+
+async def _locations_by_group(db: AsyncSession) -> dict[int, list[str]]:
+    result = await db.execute(select(group_locations.c.group_id, group_locations.c.location))
+    by_group: dict[int, list[str]] = {}
+    for group_id, location in result.all():
+        by_group.setdefault(group_id, []).append(location)
+    return {gid: sorted(locs) for gid, locs in by_group.items()}
+
+
+def _clean_locations(locations: list[str]) -> set[str]:
+    """Trimmed, non-empty location names; refuses ones too long to ever match a printer."""
+    cleaned = {loc.strip() for loc in locations if loc and loc.strip()}
+    if any(len(loc) > 100 for loc in cleaned):
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Location names are at most 100 characters",
+        )
+    return cleaned
+
+
+async def _apply_printer_scope(
+    db: AsyncSession,
+    group: Group,
+    restrict_printers: bool | None,
+    printer_ids: list[int] | None,
+    locations: list[str] | None = None,
+) -> bool:
+    """Validate and store a group's printer scope (#1727). Returns whether it changed.
+
+    The Administrators group can't be restricted: admins see every printer
+    regardless, so the setting would only mislead.
+    """
+    changed = False
+    if restrict_printers is not None and restrict_printers != bool(group.restrict_printers):
+        if restrict_printers and group.name == "Administrators":
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Administrators always see every printer",
+            )
+        group.restrict_printers = restrict_printers
+        changed = True
+    if printer_ids is not None:
+        wanted = set(printer_ids)
+        if wanted:
+            found = set((await db.execute(select(Printer.id).where(Printer.id.in_(wanted)))).scalars().all())
+            missing = sorted(wanted - found)
+            if missing:
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail=f"Invalid printers: {', '.join(str(pid) for pid in missing)}",
+                )
+        current = set(await group_printer_ids(db, group.id)) if group.id is not None else set()
+        if wanted != current:
+            if group.id is None:
+                await db.flush()
+            await db.execute(delete(group_printers).where(group_printers.c.group_id == group.id))
+            if wanted:
+                await db.execute(
+                    insert(group_printers), [{"group_id": group.id, "printer_id": pid} for pid in sorted(wanted)]
+                )
+            changed = True
+    if locations is not None:
+        # Not checked against existing printers: a location can be granted
+        # before its first printer is added there.
+        wanted_locations = _clean_locations(locations)
+        current_locations = set(await group_location_names(db, group.id)) if group.id is not None else set()
+        if wanted_locations != current_locations:
+            if group.id is None:
+                await db.flush()
+            await db.execute(delete(group_locations).where(group_locations.c.group_id == group.id))
+            if wanted_locations:
+                await db.execute(
+                    insert(group_locations),
+                    [{"group_id": group.id, "location": loc} for loc in sorted(wanted_locations)],
+                )
+            changed = True
+    return changed
+
+
+def _group_response(group: Group, printer_ids: list[int], locations: list[str], user_count: int) -> GroupResponse:
+    return GroupResponse(
+        id=group.id,
+        name=group.name,
+        description=group.description,
+        permissions=group.permissions or [],
+        is_system=group.is_system,
+        restrict_printers=bool(group.restrict_printers),
+        printer_ids=printer_ids,
+        locations=locations,
+        user_count=user_count,
+        created_at=group.created_at,
+        updated_at=group.updated_at,
+    )
+
+
 @router.get("/permissions", response_model=PermissionsListResponse)
 async def list_permissions(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
@@ -79,16 +186,11 @@ async def list_groups(
     """List all groups."""
     result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
     groups = result.scalars().all()
+    printers_by_group = await _printer_ids_by_group(db)
+    locations_by_group = await _locations_by_group(db)
     return [
-        GroupResponse(
-            id=group.id,
-            name=group.name,
-            description=group.description,
-            permissions=group.permissions or [],
-            is_system=group.is_system,
-            user_count=len(group.users),
-            created_at=group.created_at,
-            updated_at=group.updated_at,
+        _group_response(
+            group, printers_by_group.get(group.id, []), locations_by_group.get(group.id, []), len(group.users)
         )
         for group in groups
     ]
@@ -124,21 +226,15 @@ async def create_group(
         description=group_data.description,
         permissions=group_data.permissions,
         is_system=False,  # User-created groups are not system groups
+        restrict_printers=False,
     )
     db.add(group)
+    await db.flush()
+    await _apply_printer_scope(db, group, group_data.restrict_printers, group_data.printer_ids, group_data.locations)
     await db.commit()
     await db.refresh(group)
 
-    return GroupResponse(
-        id=group.id,
-        name=group.name,
-        description=group.description,
-        permissions=group.permissions or [],
-        is_system=group.is_system,
-        user_count=0,
-        created_at=group.created_at,
-        updated_at=group.updated_at,
-    )
+    return _group_response(group, await group_printer_ids(db, group.id), await group_location_names(db, group.id), 0)
 
 
 @router.get("/{group_id}", response_model=GroupDetailResponse)
@@ -163,6 +259,9 @@ async def get_group(
         description=group.description,
         permissions=group.permissions or [],
         is_system=group.is_system,
+        restrict_printers=bool(group.restrict_printers),
+        printer_ids=await group_printer_ids(db, group.id),
+        locations=await group_location_names(db, group.id),
         user_count=len(group.users),
         created_at=group.created_at,
         updated_at=group.updated_at,
@@ -226,18 +325,17 @@ async def update_group(
             )
         group.permissions = group_data.permissions
 
+    scope_changed = await _apply_printer_scope(
+        db, group, group_data.restrict_printers, group_data.printer_ids, group_data.locations
+    )
+
     await db.commit()
     await db.refresh(group)
+    if scope_changed:
+        await ws_manager.refresh_printer_scopes()
 
-    return GroupResponse(
-        id=group.id,
-        name=group.name,
-        description=group.description,
-        permissions=group.permissions or [],
-        is_system=group.is_system,
-        user_count=len(group.users),
-        created_at=group.created_at,
-        updated_at=group.updated_at,
+    return _group_response(
+        group, await group_printer_ids(db, group.id), await group_location_names(db, group.id), len(group.users)
     )
 
 
@@ -263,8 +361,14 @@ async def delete_group(
             detail="Cannot delete system groups",
         )
 
+    restricted = bool(group.restrict_printers)
+    # SQLite doesn't enforce the FK cascade
+    await db.execute(delete(group_printers).where(group_printers.c.group_id == group_id))
+    await db.execute(delete(group_locations).where(group_locations.c.group_id == group_id))
     await db.delete(group)
     await db.commit()
+    if restricted:
+        await ws_manager.refresh_printer_scopes()
 
 
 @router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -303,6 +407,8 @@ async def add_user_to_group(
 
     group.users.append(user)
     await db.commit()
+    if group.restrict_printers:
+        await ws_manager.refresh_printer_scopes()
 
 
 @router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -341,3 +447,5 @@ async def remove_user_from_group(
 
     group.users.remove(user)
     await db.commit()
+    if group.restrict_printers:
+        await ws_manager.refresh_printer_scopes()

+ 2 - 2
backend/app/api/routes/ha_sensors.py

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
@@ -88,7 +88,7 @@ async def list_bindable_entities(
 async def get_printer_sensor_readings(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = _READ,
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
     """Live state of a printer's card-visible sensors.
 

+ 18 - 2
backend/app/api/routes/inventory.py

@@ -12,13 +12,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.core.auth import (
+    RequestPrinterScope,
     RequireAnyPermissionIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
     require_auth_if_enabled,
 )
 from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG, DEFAULT_SPOOL_CATALOG
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.color_catalog import ColorCatalogEntry
@@ -2053,6 +2056,7 @@ async def list_assignments(
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_VIEW_ASSIGNMENTS),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """List spool assignments, optionally filtered by printer."""
     from backend.app.services.printer_manager import printer_manager
@@ -2063,6 +2067,8 @@ async def list_assignments(
     )
     if printer_id is not None:
         query = query.where(SpoolAssignment.printer_id == printer_id)
+    if (clause := printer_scope.where_strict(SpoolAssignment.printer_id)) is not None:
+        query = query.where(clause)
     result = await db.execute(query)
     assignments = list(result.scalars().all())
 
@@ -2118,10 +2124,13 @@ async def assign_spool(
     data: SpoolAssignmentCreate,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Assign a spool to an AMS slot and auto-configure via MQTT."""
     from backend.app.services.printer_manager import printer_manager
 
+    printer_scope.ensure(data.printer_id)
+
     # 1. Validate spool exists and is not archived
     result = await db.execute(select(Spool).options(*spool_response_loads()).where(Spool.id == data.spool_id))
     spool = result.scalar_one_or_none()
@@ -2340,7 +2349,7 @@ async def unassign_spool(
     ams_id: int,
     tray_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Unassign a spool from an AMS slot."""
     result = await db.execute(
@@ -2695,6 +2704,7 @@ async def get_all_usage_history(
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get global usage history, optionally filtered by printer."""
     from backend.app.models.spool_usage_history import SpoolUsageHistory
@@ -2702,6 +2712,8 @@ async def get_all_usage_history(
     query = select(SpoolUsageHistory).order_by(SpoolUsageHistory.created_at.desc()).limit(limit)
     if printer_id is not None:
         query = query.where(SpoolUsageHistory.printer_id == printer_id)
+    if (clause := printer_scope.where(SpoolUsageHistory.printer_id)) is not None:
+        query = query.where(clause)
     result = await db.execute(query)
     return list(result.scalars().all())
 
@@ -2729,6 +2741,7 @@ async def clear_spool_usage_history(
 async def sync_weights_from_ams(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Force-sync spool weight_used from live AMS remain% data.
 
@@ -2739,7 +2752,8 @@ async def sync_weights_from_ams(
     from backend.app.services.printer_manager import printer_manager
 
     result = await db.execute(select(SpoolAssignment).options(selectinload(SpoolAssignment.spool)))
-    assignments = list(result.scalars().all())
+    # Only slots on printers the caller may see (#1727)
+    assignments = [a for a in result.scalars().all() if printer_scope.allows(a.printer_id)]
     logger.info("AMS weight sync: found %d assignments", len(assignments))
 
     synced = 0
@@ -3107,6 +3121,7 @@ async def create_spool_from_slot(
     req: CreateSpoolFromSlotRequest,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Explicit user action: create an inventory spool from an AMS slot's current tray data.
 
@@ -3117,6 +3132,7 @@ async def create_spool_from_slot(
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.spool_tag_matcher import auto_assign_spool, create_spool_from_tray
 
+    printer_scope.ensure(req.printer_id)
     state = printer_manager.get_status(req.printer_id)
     if not state or not state.raw_data:
         raise HTTPException(status_code=404, detail="Printer not connected or no state available")

+ 8 - 8
backend/app/api/routes/kprofiles.py

@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
@@ -33,7 +33,7 @@ async def get_kprofiles(
     printer_id: int,
     nozzle_diameter: str = "0.4",
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
     """Get K-profiles from a printer.
 
@@ -82,7 +82,7 @@ async def set_kprofile(
     printer_id: int,
     profile: KProfileCreate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Create or update a K-profile on the printer.
 
@@ -198,7 +198,7 @@ async def set_kprofiles_batch(
     printer_id: int,
     profiles: list[KProfileCreate],
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Create multiple K-profiles in a single command (for dual-nozzle).
 
@@ -261,7 +261,7 @@ async def delete_kprofile(
     printer_id: int,
     profile: KProfileDelete,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
     """Delete a K-profile from the printer.
 
@@ -311,7 +311,7 @@ async def delete_kprofile(
 async def get_kprofile_notes(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
     """Get all K-profile notes for a printer.
 
@@ -339,7 +339,7 @@ async def set_kprofile_note(
     printer_id: int,
     note_data: KProfileNote,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Set or update a note for a K-profile.
 
@@ -388,7 +388,7 @@ async def delete_kprofile_note(
     printer_id: int,
     setting_id: str,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
     """Delete a note for a K-profile.
 

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

@@ -25,6 +25,8 @@ from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
 from backend.app.api.routes.print_queue import _extract_filament_types_from_3mf
 from backend.app.core.auth import (
+    QueueReviewRequired,
+    RequestPrinterScope,
     require_media_token_ownership,
     require_ownership_permission,
     require_permission_if_auth_enabled,
@@ -32,6 +34,7 @@ from backend.app.core.auth import (
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope, ensure_model_target_allowed
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryFolder
@@ -3020,6 +3023,8 @@ async def add_files_to_queue(
     request: AddToQueueRequest,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
 ):
     """Add library files to the print queue.
 
@@ -3043,9 +3048,13 @@ async def add_files_to_queue(
         raise HTTPException(400, "Cannot specify both printer_id and target_model")
 
     if request.printer_id is not None:
+        printer_scope.ensure(request.printer_id)
         printer_row = (await db.execute(select(Printer).where(Printer.id == request.printer_id))).scalar_one_or_none()
         if not printer_row:
             raise HTTPException(400, "Printer not found")
+    else:
+        # Without a printer, every file goes to "any printer of a model"
+        ensure_model_target_allowed(current_user, printer_scope)
 
     # Active printers of every model, read once, and only when the batch has no
     # printer of its own -- with one named, neither the check below nor the
@@ -3197,6 +3206,8 @@ async def add_files_to_queue(
                 # on `created_by_id` — so the user who queued the file could not
                 # see it in their own queue.
                 created_by_id=current_user.id if current_user else None,
+                # Waits for someone to start it unless they may print without review (#1620)
+                manual_start=review_required,
             )
             db.add(queue_item)
 

+ 25 - 9
backend/app/api/routes/maintenance.py

@@ -8,9 +8,14 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
@@ -440,7 +445,7 @@ async def _get_printer_maintenance_internal(
 async def get_printer_maintenance(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
     """Get maintenance overview for a specific printer."""
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
@@ -450,12 +455,13 @@ async def get_printer_maintenance(
 async def get_all_maintenance_overview(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get maintenance overview for all active printers."""
     await ensure_default_types(db)
 
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
     overviews = []
     for printer in printers:
@@ -475,6 +481,7 @@ async def update_printer_maintenance(
     data: PrinterMaintenanceUpdate,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Update a printer maintenance item (e.g., custom interval, enabled)."""
     result = await db.execute(
@@ -483,7 +490,7 @@ async def update_printer_maintenance(
         .options(selectinload(PrinterMaintenance.maintenance_type))
     )
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
     update_data = data.model_dump(exclude_unset=True)
@@ -500,7 +507,7 @@ async def assign_maintenance_type(
     printer_id: int,
     type_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
     """Assign a maintenance type to a specific printer (for custom types)."""
     # Verify printer exists
@@ -554,6 +561,7 @@ async def remove_maintenance_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Remove a maintenance item (unassign a custom type from a printer)."""
     result = await db.execute(
@@ -562,7 +570,7 @@ async def remove_maintenance_item(
         .options(selectinload(PrinterMaintenance.maintenance_type))
     )
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
     # Only allow removing custom (non-system) types
@@ -581,6 +589,7 @@ async def perform_maintenance(
     data: PerformMaintenanceRequest,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Mark maintenance as performed (reset the counter)."""
     result = await db.execute(
@@ -589,7 +598,7 @@ async def perform_maintenance(
         .options(selectinload(PrinterMaintenance.maintenance_type))
     )
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(status_code=404, detail="Maintenance item not found")
 
     # Get printer for name
@@ -659,8 +668,14 @@ async def get_maintenance_history(
     item_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get maintenance history for a specific item."""
+    item_printer_id = (
+        await db.execute(select(PrinterMaintenance.printer_id).where(PrinterMaintenance.id == item_id))
+    ).scalar_one_or_none()
+    if not printer_scope.allows(item_printer_id):
+        raise HTTPException(status_code=404, detail="Maintenance item not found")
     result = await db.execute(
         select(MaintenanceHistory)
         .where(MaintenanceHistory.printer_maintenance_id == item_id)
@@ -673,12 +688,13 @@ async def get_maintenance_history(
 async def get_maintenance_summary(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get a summary of maintenance status across all printers."""
     await ensure_default_types(db)
 
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
     total_due = 0
     total_warning = 0
@@ -710,7 +726,7 @@ async def set_printer_hours(
     printer_id: int,
     total_hours: float,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Set the total print hours for a printer (adjusts offset to match).
 

+ 7 - 1
backend/app/api/routes/obico.py

@@ -5,8 +5,9 @@ import logging
 from fastapi import APIRouter, HTTPException, Response
 from pydantic import BaseModel
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.user import User
 from backend.app.services.obico_detection import obico_detection_service, pop_frame
 
@@ -42,6 +43,7 @@ async def get_status(
 @router.get("/printer-status")
 async def get_printer_status(
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Per-printer live classification for the printer cards (#1546).
 
@@ -59,6 +61,10 @@ async def get_printer_status(
         # needs to know their print is not being watched. Only the reason, which
         # can name a URL, is withheld.
         per_printer = {pid: {**entry, "error": None} for pid, entry in per_printer.items()}
+    # Only printers the caller may see (#1727)
+    per_printer = {pid: entry for pid, entry in per_printer.items() if printer_scope.allows(int(pid))}
+    if enabled_printers is not None:
+        enabled_printers = [pid for pid in enabled_printers if printer_scope.allows(int(pid))]
     return {
         "enabled": settings["enabled"],
         # None = all printers are monitored

+ 2 - 2
backend/app/api/routes/overlay_branding.py

@@ -10,7 +10,7 @@ from fastapi.responses import Response
 from PIL import Image, UnidentifiedImageError
 from starlette.concurrency import run_in_threadpool
 
-from backend.app.core.auth import RequireOverlayTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequireOverlayTokenAnyPrinterIfAuthEnabled, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
@@ -64,7 +64,7 @@ def get_logo(_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS
 
 
 @router.get("/overlay-branding/logo")
-def get_stream_logo(_: None = RequireOverlayTokenIfAuthEnabled):
+def get_stream_logo(_: None = RequireOverlayTokenAnyPrinterIfAuthEnabled):
     return _read_logo()
 
 

+ 84 - 26
backend/app/api/routes/pipeline_runs.py

@@ -34,10 +34,11 @@ from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.api.routes.cloud import resolve_api_key_cloud_owner
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import QueueReviewRequired, RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope, ensure_model_target_allowed
 from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
@@ -377,6 +378,7 @@ async def _resolve_source(
     library_file_id: int | None,
     archive_id: int | None,
     user: User | None,
+    printer_scope: PrinterScope,
 ) -> tuple[SourceKind, int, str, Path]:
     # Per-row ownership gate (IDOR fix): a caller may only run a pipeline on a
     # source they can see. Without this a READ_OWN caller could reference
@@ -401,7 +403,7 @@ async def _resolve_source(
     assert archive_id is not None
     arc = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
     can_read_all = user is None or user.has_permission(Permission.ARCHIVES_READ_ALL.value)
-    arc = _ensure_archive_visible(arc, user, can_read_all)
+    arc = _ensure_archive_visible(arc, user, can_read_all, printer_scope)
     rel = arc.source_3mf_path or arc.file_path
     if not rel:
         raise HTTPException(400, "Archive has no source file to slice")
@@ -418,11 +420,13 @@ async def _pick_assignments(
     db: AsyncSession,
     pipeline: SlicerPipeline,
     copies: int,
+    printer_scope: PrinterScope = ALL_PRINTERS,
 ) -> list[tuple[int | None, str | None]]:
     """Return ``[(printer_id_or_None, target_model_or_None), ...]`` of length
     ``copies`` per the pipeline's fanout strategy. ``target_model_class``
     items leave ``printer_id`` None so the scheduler picks any free matching
-    printer; specific assignments fill ``printer_id``."""
+    printer; specific assignments fill ``printer_id``. Class targeting only
+    pins copies to printers in the runner's ``printer_scope`` (#1727)."""
     target_kind = pipeline.target_kind or "specific_printer"
     if target_kind == "specific_printer" or pipeline.target_printer_id is not None:
         assert pipeline.target_printer_id is not None
@@ -441,6 +445,7 @@ async def _pick_assignments(
         .scalars()
         .all()
     )
+    matching = [p for p in matching if printer_scope.allows(p.id)]
     if not matching:
         # Shouldn't reach here when eligibility passes, but failing gracefully
         # is better than a TypeError on next-slot pick.
@@ -471,6 +476,8 @@ def _make_orchestration_callable(
     src_path: Path,
     creator_user_id: int | None,
     copies: int,
+    printer_scope: PrinterScope = ALL_PRINTERS,
+    review_required: bool = False,
 ):
     """Returns the async callable that ``slice_dispatch.enqueue`` runs as the
     background slice job. Wraps slice + multi-copy enqueue + state update."""
@@ -556,7 +563,7 @@ def _make_orchestration_callable(
                 return slice_response.model_dump()
 
             # PR C: enqueue N copies per the picked assignment strategy.
-            assignments = await _pick_assignments(session, pipeline, copies)
+            assignments = await _pick_assignments(session, pipeline, copies, printer_scope)
 
             jobs = (
                 (
@@ -583,6 +590,8 @@ def _make_orchestration_callable(
                     created_by_id=creator_user_id,
                     status="pending",
                     confirm_outcome=confirm_outcome,
+                    # The copies wait for review like any other job of theirs (#1620)
+                    manual_start=review_required,
                 )
                 session.add(queue_item)
                 await session.flush()
@@ -642,14 +651,17 @@ async def check_eligibility(
     pipeline_id: int,
     body: CheckEligibilityRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
 ):
     pipeline = await _load_pipeline(db, pipeline_id)
+    printer_scope.ensure(pipeline.target_printer_id)
     await _resolve_source(
         db,
         library_file_id=body.source_library_file_id,
         archive_id=body.source_archive_id,
         user=current_user,
+        printer_scope=printer_scope,
     )
     if pipeline.target_kind == "printer_class" and pipeline.target_printer_id is None:
         report = await check_pipeline_eligibility(db, pipeline, status_lookup=_make_status_lookup())
@@ -670,12 +682,17 @@ async def run_pipeline(
     body: PipelineRunCreateRequest,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
     db: AsyncSession = Depends(get_db),
 ):
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.slice_dispatch import slice_dispatch
 
     pipeline = await _load_pipeline(db, pipeline_id)
+    # The pipeline is shared config; running it is limited to what the caller
+    # may print on (#1727)
+    printer_scope.ensure(pipeline.target_printer_id)
     # ``user=current_user`` deliberately, not the cloud owner below: an API-key
     # caller has no per-row identity and must keep can_read_all, the same as
     # every other read helper.
@@ -684,6 +701,7 @@ async def run_pipeline(
         library_file_id=body.source_library_file_id,
         archive_id=body.source_archive_id,
         user=current_user,
+        printer_scope=printer_scope,
     )
 
     # The permission gate answers an API-keyed request with current_user=None,
@@ -693,6 +711,13 @@ async def run_pipeline(
     # Only keys with the cloud scope resolve to an owner here; everything else
     # stays None and slices against local presets exactly as before.
     creator = current_user or api_key_cloud_owner
+    # Copies left to the scheduler run within their creator's printers. A
+    # limited API key can't be held to its own printers that way -- its cloud
+    # owner may see more -- and even a pinning strategy falls back to "any
+    # printer of the class" when none of the class is in scope, so a limited
+    # key may only run pipelines aimed at one printer (#1727).
+    if pipeline.target_printer_id is None:
+        ensure_model_target_allowed(current_user, printer_scope)
 
     # Cap copies against the configured ceiling.
     raw_cap = await get_setting(db, "pipeline_max_copies")
@@ -757,6 +782,8 @@ async def run_pipeline(
         src_path=src_path,
         creator_user_id=creator.id if creator else None,
         copies=body.copies,
+        printer_scope=printer_scope,
+        review_required=review_required,
     )
     slice_job = await slice_dispatch.enqueue(
         kind="library_file" if src_kind == "library_file" else "archive",
@@ -778,29 +805,51 @@ async def run_pipeline(
 # ---------------------------------------------------------------------------
 
 
+def _run_scope_clause(printer_scope: PrinterScope):
+    """Runs the caller may see (#1727): their pipeline targets no printer out of
+    scope, and no copy was assigned to one. None when unrestricted."""
+    if printer_scope.is_unrestricted:
+        return None
+    allowed = printer_scope.printer_ids
+    hidden_by_job = select(PipelineJob.pipeline_run_id).where(
+        PipelineJob.assigned_printer_id.is_not(None), PipelineJob.assigned_printer_id.not_in(allowed)
+    )
+    hidden_pipelines = select(SlicerPipeline.id).where(
+        SlicerPipeline.target_printer_id.is_not(None), SlicerPipeline.target_printer_id.not_in(allowed)
+    )
+    return PipelineRun.id.not_in(hidden_by_job) & (
+        PipelineRun.pipeline_id.is_(None) | PipelineRun.pipeline_id.not_in(hidden_pipelines)
+    )
+
+
+async def _load_run_in_scope(db: AsyncSession, run_id: int, printer_scope: PrinterScope) -> PipelineRun:
+    query = select(PipelineRun).where(PipelineRun.id == run_id)
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        query = query.where(clause)
+    run = (await db.execute(query)).scalar_one_or_none()
+    if run is None:
+        raise HTTPException(404, "Pipeline run not found")
+    return run
+
+
 @pipeline_run_create_router.get("/{pipeline_id}/runs", response_model=PipelineRunListResponse)
 async def list_runs_for_pipeline(
     pipeline_id: int,
     limit: int = 10,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     limit = max(1, min(limit, 100))
+    conditions = [PipelineRun.pipeline_id == pipeline_id]
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        conditions.append(clause)
     rows = (
-        (
-            await db.execute(
-                select(PipelineRun)
-                .where(PipelineRun.pipeline_id == pipeline_id)
-                .order_by(PipelineRun.id.desc())
-                .limit(limit)
-            )
-        )
+        (await db.execute(select(PipelineRun).where(*conditions).order_by(PipelineRun.id.desc()).limit(limit)))
         .scalars()
         .all()
     )
-    total = (
-        await db.execute(select(func.count()).select_from(PipelineRun).where(PipelineRun.pipeline_id == pipeline_id))
-    ).scalar() or 0
+    total = (await db.execute(select(func.count()).select_from(PipelineRun).where(*conditions))).scalar() or 0
     return PipelineRunListResponse(
         runs=[await _materialise_run(db, r) for r in rows],
         total=total,
@@ -817,6 +866,7 @@ async def list_all_runs(
     target_model_class: str | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Dashboard list. Newest first; filters on pipeline_id + status +
     target_printer_id + target_model_class. The ``status`` filter matches
@@ -829,6 +879,9 @@ async def list_all_runs(
 
     stmt = select(PipelineRun)
     count_stmt = select(func.count()).select_from(PipelineRun)
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        stmt = stmt.where(clause)
+        count_stmt = count_stmt.where(clause)
     if pipeline_id is not None:
         stmt = stmt.where(PipelineRun.pipeline_id == pipeline_id)
         count_stmt = count_stmt.where(PipelineRun.pipeline_id == pipeline_id)
@@ -861,6 +914,7 @@ _TERMINAL_RUN_STATUSES = ("completed", "failed", "cancelled", "partial_failure")
 async def clear_terminal_runs(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_WRITE),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Delete every terminal pipeline run (completed / failed / cancelled /
     partial_failure). In-flight runs (queued / slicing / dispatching /
@@ -871,10 +925,14 @@ async def clear_terminal_runs(
     # Count first so the response can report how many got cleared. Done
     # under the same session/transaction as the delete so the numbers can't
     # drift if another caller races in.
-    count_stmt = select(func.count()).select_from(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES))
+    # Runs on printers the caller can't see are left alone (#1727)
+    conditions = [PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)]
+    if (clause := _run_scope_clause(printer_scope)) is not None:
+        conditions.append(clause)
+    count_stmt = select(func.count()).select_from(PipelineRun).where(*conditions)
     n = (await db.execute(count_stmt)).scalar() or 0
     if n > 0:
-        await db.execute(delete(PipelineRun).where(PipelineRun.status.in_(_TERMINAL_RUN_STATUSES)))
+        await db.execute(delete(PipelineRun).where(*conditions))
         await db.commit()
     return {"deleted": n}
 
@@ -884,10 +942,9 @@ async def get_run(
     run_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_READ),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
-    if run is None:
-        raise HTTPException(404, "Pipeline run not found")
+    run = await _load_run_in_scope(db, run_id, printer_scope)
     return await _materialise_run(db, run)
 
 
@@ -896,12 +953,11 @@ async def cancel_run(
     run_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     db: AsyncSession = Depends(get_db),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Cancel a queued / in-flight run. Cascades to all non-terminal queue
     entries; in-flight prints continue on the printer (operator must Stop)."""
-    run = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
-    if run is None:
-        raise HTTPException(404, "Pipeline run not found")
+    run = await _load_run_in_scope(db, run_id, printer_scope)
 
     if run.status in ("completed", "failed", "cancelled", "partial_failure"):
         return await _materialise_run(db, run)
@@ -934,14 +990,14 @@ async def retry_failed(
     run_id: int,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PIPELINES_RUN),
     api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
     db: AsyncSession = Depends(get_db),
 ):
     """Create a new run with copies = (failed + cancelled count) from the
     parent. Same pipeline, same source. Eligibility re-checked at run time
     (it might pass this time — operator may have fixed the issue)."""
-    parent = (await db.execute(select(PipelineRun).where(PipelineRun.id == run_id))).scalar_one_or_none()
-    if parent is None:
-        raise HTTPException(404, "Pipeline run not found")
+    parent = await _load_run_in_scope(db, run_id, printer_scope)
     if parent.pipeline_id is None:
         raise HTTPException(400, "Original pipeline was deleted; cannot retry")
     if parent.source_library_file_id is None and parent.source_archive_id is None:
@@ -983,6 +1039,8 @@ async def retry_failed(
         body,
         current_user=current_user,
         api_key_cloud_owner=api_key_cloud_owner,
+        printer_scope=printer_scope,
+        review_required=review_required,
         db=db,
     )
 

+ 20 - 4
backend/app/api/routes/print_log.py

@@ -7,6 +7,8 @@ from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
+    MediaOrRequestPrinterScope,
+    RequestPrinterScope,
     RequirePermissionIfAuthEnabled,
     require_media_token_ownership,
     require_ownership_permission,
@@ -14,6 +16,7 @@ from backend.app.core.auth import (
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.user import User
 from backend.app.schemas.print_log import PrintLogEntrySchema, PrintLogEntryUpdate, PrintLogResponse
@@ -66,6 +69,7 @@ async def get_print_log(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get the print log."""
     user, can_read_all = auth_result
@@ -74,6 +78,10 @@ async def get_print_log(
     if user is not None and not can_read_all:
         query = query.where(PrintLogEntry.created_by_id == user.id)
         count_query = count_query.where(PrintLogEntry.created_by_id == user.id)
+    # Only prints on printers the caller may see (#1727)
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        query = query.where(clause)
+        count_query = count_query.where(clause)
 
     if printer_id is not None:
         query = query.where(PrintLogEntry.printer_id == printer_id)
@@ -142,6 +150,7 @@ async def get_print_log_thumbnail(
             Permission.ARCHIVES_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = MediaOrRequestPrinterScope,
 ):
     """Get the thumbnail for a print log entry.
 
@@ -159,7 +168,7 @@ async def get_print_log_thumbnail(
     """
     user, can_read_all = auth_result
     entry = await db.get(PrintLogEntry, entry_id)
-    if not entry or not entry.thumbnail_path:
+    if not entry or not entry.thumbnail_path or not printer_scope.allows(entry.printer_id):
         raise HTTPException(404, "Thumbnail not found")
     if not can_read_all and (user is None or entry.created_by_id != user.id):
         raise HTTPException(404, "Thumbnail not found")
@@ -181,12 +190,17 @@ async def get_print_log_thumbnail(
 async def clear_print_log(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Clear the print log.
 
     Only deletes log entries. Archives and queue items are never touched.
     """
-    result = await db.execute(delete(PrintLogEntry))
+    # Entries for printers the caller can't see stay (#1727)
+    statement = delete(PrintLogEntry)
+    if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+        statement = statement.where(clause)
+    result = await db.execute(statement)
     deleted = result.rowcount
     await db.commit()
 
@@ -204,6 +218,7 @@ async def delete_print_log_entry(
             Permission.ARCHIVES_DELETE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Delete a single print-log entry (#1687).
 
@@ -216,7 +231,7 @@ async def delete_print_log_entry(
     user, can_modify_all = auth_result
 
     entry = await db.get(PrintLogEntry, entry_id)
-    if not entry:
+    if not entry or not printer_scope.allows(entry.printer_id):
         raise HTTPException(404, "Print log entry not found")
 
     if not can_modify_all:
@@ -273,6 +288,7 @@ async def update_print_log_entry(
             Permission.ARCHIVES_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Edit a single Print Log row's classification (#1687 part 4, reporter
     IndividualGhost1905).
@@ -290,7 +306,7 @@ async def update_print_log_entry(
     user, can_modify_all = auth_result
 
     entry = await db.get(PrintLogEntry, entry_id)
-    if not entry:
+    if not entry or not printer_scope.allows(entry.printer_id):
         raise HTTPException(404, "Print log entry not found")
 
     if not can_modify_all:

+ 107 - 12
backend/app/api/routes/print_queue.py

@@ -14,10 +14,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.library_variants import normalize_model_name, resolve_variant_model
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
+from backend.app.core.auth import (
+    QueueReviewRequired,
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+    may_start_queue_item,
+    require_ownership_permission,
+)
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope, ensure_model_target_allowed
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
@@ -67,6 +75,9 @@ logger = logging.getLogger(__name__)
 
 router = APIRouter(prefix="/queue", tags=["queue"])
 
+# Answer to a caller who may not start a job that waits for review (#1620)
+_AWAITING_REVIEW = "This job waits for review: someone who can manage all queue jobs has to start it"
+
 
 def _variant_summaries(item: PrintQueueItem) -> list[QueueVariantSummary]:
     """Cross-model candidates for display (#671), or [] if they weren't loaded.
@@ -602,6 +613,7 @@ async def list_queue(
             Permission.QUEUE_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """List all queue items, optionally filtered by printer or status."""
     user, can_read_all = auth_result
@@ -625,6 +637,9 @@ async def list_queue(
     )
     if user is not None and not can_read_all:
         query = query.where(PrintQueueItem.created_by_id == user.id)
+    # Items bound to printers the caller can't see stay out (#1727)
+    if (clause := printer_scope.where(PrintQueueItem.printer_id)) is not None:
+        query = query.where(clause)
 
     if printer_id is not None:
         if printer_id == -1:
@@ -810,6 +825,8 @@ async def add_to_queue(
     data: PrintQueueItemCreate,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
 ):
     """Add an item to the print queue."""
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E").
@@ -851,9 +868,12 @@ async def add_to_queue(
     # Cannot specify both printer_id and target_model
     if data.printer_id and target_model_norm:
         raise HTTPException(400, "Cannot specify both printer_id and target_model")
+    if target_model_norm:
+        ensure_model_target_allowed(current_user, printer_scope)
 
     # Validate printer exists (if assigned)
     if data.printer_id is not None:
+        printer_scope.ensure(data.printer_id)
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
@@ -1153,7 +1173,9 @@ async def add_to_queue(
             scheduled_time=data.scheduled_time,
             require_previous_success=data.require_previous_success,
             auto_off_after=data.auto_off_after,
-            manual_start=data.manual_start,
+            # Without queue:start_unreviewed the job waits for someone to
+            # start it, whatever the request asked for (#1620)
+            manual_start=data.manual_start or review_required,
             skip_filament_check=data.skip_filament_check,
             ams_mapping=ams_mapping_json,
             nozzle_rack_choice=nozzle_rack_choice_json,
@@ -1259,6 +1281,7 @@ async def bulk_update_queue_items(
             Permission.QUEUE_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Bulk update multiple queue items with the same values.
 
@@ -1277,13 +1300,14 @@ async def bulk_update_queue_items(
 
     # Validate printer_id if being changed
     if "printer_id" in update_data and update_data["printer_id"] is not None:
+        printer_scope.ensure(update_data["printer_id"])
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
-    # Fetch all items
+    # Fetch all items, minus any bound to a printer the caller can't see (#1727)
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
-    items = result.scalars().all()
+    items = [item for item in result.scalars().all() if printer_scope.allows(item.printer_id)]
 
     updated_count = 0
     skipped_count = 0
@@ -1302,6 +1326,17 @@ async def bulk_update_queue_items(
             skipped_count += 1
             continue
 
+        # Clearing "wait for manual start" starts the job, which needs the
+        # same right as the start button (#1620)
+        if (
+            item.manual_start
+            and "manual_start" in update_data
+            and not update_data["manual_start"]
+            and not may_start_queue_item(user, can_modify_all, item.created_by_id)
+        ):
+            skipped_count += 1
+            continue
+
         item_update_data = update_data.copy()
         if validates_billing_fields:
             trusted_estimated_cost = await _trusted_item_estimated_cost(
@@ -1404,11 +1439,26 @@ async def _load_batch_for_write(
 _EXTERNAL_REF_TAKEN = "A batch for this external_source and external_ref already exists"
 
 
+async def _ensure_batch_in_scope(db: AsyncSession, batch_id: int, printer_scope: PrinterScope) -> None:
+    """404 a batch holding jobs on printers the caller can't see (#1727).
+
+    Cancelling or ungrouping acts on every member, so doing it to only the
+    visible ones would leave a batch that reads cancelled with jobs still
+    pending, and refusing outright is the honest answer.
+    """
+    if printer_scope.is_unrestricted:
+        return
+    result = await db.execute(select(PrintQueueItem.printer_id).where(PrintQueueItem.batch_id == batch_id))
+    if not all(printer_scope.allows(pid) for (pid,) in result.all()):
+        raise HTTPException(404, "Batch not found")
+
+
 @router.post("/batches", response_model=PrintBatchResponse)
 async def create_batch(
     data: PrintBatchCreate,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Create a batch.
 
@@ -1485,6 +1535,8 @@ async def create_batch(
                 continue
             if item.batch_id is not None:
                 continue
+            if not printer_scope.allows(item.printer_id):
+                continue
             if (
                 current_user is not None
                 and item.created_by_id != current_user.id
@@ -1578,6 +1630,8 @@ async def dispatch_batch(
     data: PrintBatchDispatchRequest,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
+    review_required: bool = QueueReviewRequired,
 ):
     """Queue the runs this order still owes (#342).
 
@@ -1604,6 +1658,21 @@ async def dispatch_batch(
         )
     except BatchDispatchError as exc:
         raise HTTPException(400, str(exc)) from exc
+    # The clones inherit their templates' targets, which whoever queued them
+    # was allowed; the dispatcher has to be allowed them too (#1727).
+    try:
+        for item in created:
+            printer_scope.ensure(item.printer_id)
+            if item.printer_id is None:
+                ensure_model_target_allowed(current_user, printer_scope)
+    except HTTPException:
+        await db.rollback()
+        raise
+    # A clone copies its template's "wait for manual start", which is off once
+    # staff started the template; the dispatcher's own jobs still wait (#1620)
+    if review_required:
+        for item in created:
+            item.manual_start = True
 
     await db.commit()
     await db.refresh(batch)
@@ -1617,6 +1686,7 @@ async def ungroup_batch(
     batch_id: int,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Disband a batch: clear batch_id from all members and delete the batch row.
 
@@ -1631,6 +1701,7 @@ async def ungroup_batch(
     can_modify_all = current_user is None or current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
     if not can_modify_all and batch.created_by_id != (current_user.id if current_user else None):
         raise HTTPException(404, "Batch not found")
+    await _ensure_batch_in_scope(db, batch_id, printer_scope)
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id))
     items = result.scalars().all()
@@ -1739,12 +1810,14 @@ async def cancel_batch(
     batch_id: int,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Cancel all pending items in a batch and mark batch as cancelled."""
     result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
     batch = result.scalar_one_or_none()
     if not batch:
         raise HTTPException(404, "Batch not found")
+    await _ensure_batch_in_scope(db, batch_id, printer_scope)
 
     # Cancel all pending queue items in this batch
     result = await db.execute(
@@ -1861,6 +1934,7 @@ async def get_queue_item(
             Permission.QUEUE_READ_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get a specific queue item."""
     current_user, can_read_all = auth_result
@@ -1878,7 +1952,7 @@ async def get_queue_item(
         .where(PrintQueueItem.id == item_id)
     )
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
     if (
         current_user is not None
@@ -1900,6 +1974,7 @@ async def update_queue_item(
             Permission.QUEUE_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Update a queue item."""
     user, can_modify_all = auth_result
@@ -1913,7 +1988,7 @@ async def update_queue_item(
         .where(PrintQueueItem.id == item_id)
     )
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
 
     # Ownership check
@@ -1934,10 +2009,21 @@ async def update_queue_item(
 
     update_data = data.model_dump(exclude_unset=True)
 
+    # Clearing "wait for manual start" starts the job, which needs the same
+    # right as the start button (#1620)
+    if (
+        item.manual_start
+        and "manual_start" in update_data
+        and not update_data["manual_start"]
+        and not may_start_queue_item(user, can_modify_all, item.created_by_id)
+    ):
+        raise HTTPException(403, _AWAITING_REVIEW)
+
     # Normalize target_model if being updated (see add_to_queue for why the
     # code map has to run first).
     if "target_model" in update_data and update_data["target_model"]:
         update_data["target_model"] = normalize_model_name(update_data["target_model"])
+        ensure_model_target_allowed(user, printer_scope)
 
     # A cross-model item (#671) owns its own printer decision: each candidate
     # carries its model, and the resolver folds the winner onto the row at
@@ -1965,6 +2051,7 @@ async def update_queue_item(
 
     # Validate new printer_id if being changed (and not None)
     if "printer_id" in update_data and update_data["printer_id"] is not None:
+        printer_scope.ensure(update_data["printer_id"])
         result = await db.execute(select(Printer).where(Printer.id == update_data["printer_id"]))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
@@ -2072,6 +2159,7 @@ async def delete_queue_item(
             Permission.QUEUE_DELETE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Remove an item from the queue.
 
@@ -2086,7 +2174,7 @@ async def delete_queue_item(
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
 
     # Ownership check
@@ -2137,12 +2225,14 @@ async def reorder_queue(
     data: PrintQueueReorder,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Bulk update positions for queue items."""
     for reorder_item in data.items:
         result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == reorder_item.id))
         item = result.scalar_one_or_none()
-        if item and item.status == "pending":
+        # Jobs on printers the caller can't see keep their place (#1727)
+        if item and item.status == "pending" and printer_scope.allows(item.printer_id):
             item.position = reorder_item.position
 
     await db.commit()
@@ -2154,7 +2244,7 @@ async def reorder_queue(
 async def resume_queue_after_failure(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
 ):
     """Clear the previous-success gate for a printer and restore skipped items.
 
@@ -2218,13 +2308,14 @@ async def cancel_queue_item(
             Permission.QUEUE_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Cancel a pending queue item."""
     user, can_modify_all = auth_result
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
 
     # Ownership check
@@ -2265,6 +2356,7 @@ async def stop_queue_item(
             Permission.QUEUE_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Stop an actively printing queue item.
 
@@ -2280,7 +2372,7 @@ async def stop_queue_item(
 
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
 
     # Ownership check — mirrors /cancel. Ownerless items (created_by_id IS NULL)
@@ -2369,6 +2461,7 @@ async def start_queue_item(
             Permission.QUEUE_UPDATE_OWN,
         )
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Manually start a staged (manual_start) queue item.
 
@@ -2401,7 +2494,7 @@ async def start_queue_item(
         .where(PrintQueueItem.id == item_id)
     )
     item = result.scalar_one_or_none()
-    if not item:
+    if not item or not printer_scope.allows(item.printer_id):
         raise HTTPException(404, "Queue item not found")
 
     # Ownership check — softer than /cancel because /start is the entry point
@@ -2411,6 +2504,8 @@ async def start_queue_item(
     if not can_modify_all and user is not None:
         if item.created_by_id is not None and item.created_by_id != user.id:
             raise HTTPException(403, "You can only start your own queue items")
+    if not may_start_queue_item(user, can_modify_all, item.created_by_id):
+        raise HTTPException(403, _AWAITING_REVIEW)
 
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")

+ 122 - 14
backend/app/api/routes/printer_locations.py

@@ -10,19 +10,28 @@ Every write is one transaction on the server. The page used to send one PATCH
 per printer from its cached printer list: a failure halfway left a location
 split under two names, and a printer another user had moved in the meantime
 was moved back.
+
+Locations are also an access setting (#1727): a restricted group can be given
+a location, and then reaches every printer in it. So these routes follow the
+same rules as editing a printer: moving printers into or out of a granted
+location is for admins only, a rename carries the grants along to the new
+name, and a caller limited to some printers sees and changes only locations
+made of their own printers.
 """
 
 import logging
 
 from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import delete, func, select, update
+from sqlalchemy import delete, func, insert, select, update
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled, is_auth_enabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope, location_grantees
 from backend.app.core.websocket import ws_manager
+from backend.app.models.group import group_locations
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.printer import Printer
 from backend.app.models.printer_location import PrinterLocation
@@ -50,13 +59,44 @@ def _blank_location():
     return (Printer.location.is_(None)) | (func.trim(Printer.location) == "")
 
 
-async def _printer_counts(db: AsyncSession) -> dict[str, int]:
-    result = await db.execute(
-        select(Printer.location, func.count(Printer.id)).where(~_blank_location()).group_by(Printer.location)
-    )
+async def _printer_counts(db: AsyncSession, scope: PrinterScope | None = None) -> dict[str, int]:
+    """Printers per location; with ``scope``, only the printers in it."""
+    query = select(Printer.location, func.count(Printer.id)).where(~_blank_location())
+    limit = scope.where_strict(Printer.id) if scope is not None else None
+    if limit is not None:
+        query = query.where(limit)
+    result = await db.execute(query.group_by(Printer.location))
     return dict(result.all())
 
 
+async def _ensure_all_in_scope(db: AsyncSession, scope: PrinterScope, names: set[str]) -> None:
+    """Refuse a limited caller a location that also holds printers they can't see.
+
+    Renaming or deleting it would change those printers too.
+    """
+    if scope.is_unrestricted or not names:
+        return
+    ids = (await db.execute(select(Printer.id).where(Printer.location.in_(names)))).scalars().all()
+    if any(not scope.allows(pid) for pid in ids):
+        raise HTTPException(
+            status_code=403,
+            detail="This location also holds printers you can't access, so only someone who can see them all may change it.",
+        )
+
+
+async def _ensure_admin_for_access_change(db: AsyncSession, user: User | None, grantees: list[str]) -> None:
+    """Moving printers into or out of a granted location changes who reaches them (#1727)."""
+    if grantees and await is_auth_enabled(db) and not (user is not None and user.is_admin):
+        raise HTTPException(
+            status_code=403,
+            detail=(
+                "This changes which printers the groups "
+                + ", ".join(grantees)
+                + " can access through their locations. Only an admin can do that."
+            ),
+        )
+
+
 async def _row(db: AsyncSession, name: str) -> PrinterLocation | None:
     result = await db.execute(select(PrinterLocation).where(PrinterLocation.name == name))
     return result.scalar_one_or_none()
@@ -88,6 +128,33 @@ async def _conflicts(db: AsyncSession, name: str) -> bool:
     return await _name_taken(db, name)
 
 
+async def _move_grants(db: AsyncSession, old: str, new: str) -> None:
+    """Re-point the groups' grants of ``old`` to ``new`` (#1727).
+
+    A group already granted ``new`` keeps one grant: the pair is the primary key.
+    """
+    holders = (
+        (await db.execute(select(group_locations.c.group_id).where(group_locations.c.location == old))).scalars().all()
+    )
+    if not holders:
+        return
+    already = set(
+        (
+            await db.execute(
+                select(group_locations.c.group_id).where(
+                    group_locations.c.location == new, group_locations.c.group_id.in_(holders)
+                )
+            )
+        )
+        .scalars()
+        .all()
+    )
+    await db.execute(delete(group_locations).where(group_locations.c.location == old))
+    rows = [{"group_id": gid, "location": new} for gid in holders if gid not in already]
+    if rows:
+        await db.execute(insert(group_locations), rows)
+
+
 async def _broadcast() -> None:
     await ws_manager.broadcast({"type": "printer_locations_changed"})
 
@@ -96,10 +163,17 @@ async def _broadcast() -> None:
 async def list_printer_locations(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """Every location: those with a row, and those only printers carry."""
-    counts = await _printer_counts(db)
+    """Every location: those with a row, and those only printers carry.
+
+    A caller limited to some printers (#1727) sees only the locations their
+    printers are in, counted over those printers.
+    """
+    counts = await _printer_counts(db, printer_scope)
     rows = (await db.execute(select(PrinterLocation))).scalars().all()
+    if not printer_scope.is_unrestricted:
+        rows = [row for row in rows if row.name in counts]
     out = {
         row.name: PrinterLocationResponse(
             id=row.id, name=row.name, icon=row.icon, color=row.color, printer_count=counts.get(row.name, 0)
@@ -142,7 +216,8 @@ async def create_printer_location(
 async def update_printer_location(
     data: PrinterLocationUpdate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Rename a location and/or change its icon and colour.
 
@@ -154,13 +229,22 @@ async def update_printer_location(
     counts = await _printer_counts(db)
     if row is None and data.name not in counts:
         raise HTTPException(status_code=404, detail="Location not found")
+    if not printer_scope.is_unrestricted and data.name not in await _printer_counts(db, printer_scope):
+        raise HTTPException(status_code=404, detail="Location not found")
+    await _ensure_all_in_scope(db, printer_scope, {data.name})
 
     name = data.name
     new_name = data.new_name if "new_name" in data.model_fields_set else None
     if new_name is not None and new_name != name:
         if await _name_taken(db, new_name, ignore=name):
             raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
+        # A grant left on the new name (#1727) would hand these printers to
+        # another group: that is an access change.
+        await _ensure_admin_for_access_change(db, user, await location_grantees(db, [new_name]))
         await db.execute(update(Printer).where(Printer.location == name).values(location=new_name))
+        # The groups given this location keep it under its new name, so the
+        # rename changes no one's access.
+        await _move_grants(db, name, new_name)
         # Every row, not only pending ones: a batch clones its next run from
         # its newest row whatever that row's status, so a finished run still
         # pointing at the old name would send future runs nowhere. A rename is
@@ -197,20 +281,35 @@ async def update_printer_location(
 async def delete_printer_locations(
     data: PrinterLocationDelete,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Delete locations; their printers end up with no location.
 
     Pending queue items that target a deleted location are left alone: changing
     them to "any location" would let them start on printers they were meant to
     stay off.
+
+    A location given to a group (#1727) can only be deleted by an admin, and
+    its grants go with it: left behind, they would hand a later location of
+    the same name to that group.
     """
     names = set(data.names)
+    if not printer_scope.is_unrestricted:
+        # A limited caller can only reach locations made of their own printers.
+        names &= set(await _printer_counts(db, printer_scope))
+    await _ensure_all_in_scope(db, printer_scope, names)
+    grantees = await location_grantees(db, names)
+    await _ensure_admin_for_access_change(db, user, grantees)
     existing = set((await db.execute(select(PrinterLocation.name).where(PrinterLocation.name.in_(names)))).scalars())
     existing |= set(await _printer_counts(db)) & names
     await db.execute(delete(PrinterLocation).where(PrinterLocation.name.in_(names)))
     moved = await db.execute(update(Printer).where(Printer.location.in_(names)).values(location=None))
+    if names:
+        await db.execute(delete(group_locations).where(group_locations.c.location.in_(names)))
     await db.commit()
+    if grantees:
+        await ws_manager.refresh_printer_scopes()
     await _broadcast()
     return PrinterLocationDeleteResult(deleted=len(existing), printers_ungrouped=moved.rowcount or 0)
 
@@ -219,22 +318,31 @@ async def delete_printer_locations(
 async def assign_printer_location(
     data: PrinterLocationAssign,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Move printers into a location, or out of any with null.
 
     By printer id, on the server: a printer someone else moved meanwhile is
-    moved again only if it is in this request.
+    moved again only if it is in this request. A printer outside the caller's
+    scope (#1727) is "not found", and moving printers into or out of a
+    location given to a group is for admins only, as when editing a printer.
     """
     # Moving into a new case variant of an existing location would split it.
     if data.location is not None and await _conflicts(db, data.location):
         raise HTTPException(status_code=409, detail=DUPLICATE_NAME)
     ids = list(dict.fromkeys(data.printer_ids))
-    found = (await db.execute(select(Printer.id).where(Printer.id.in_(ids)))).scalars().all()
-    missing = sorted(set(ids) - set(found))
+    rows = (await db.execute(select(Printer.id, Printer.location).where(Printer.id.in_(ids)))).all()
+    found = {pid for pid, _ in rows if printer_scope.allows(pid)}
+    missing = sorted(set(ids) - found)
     if missing:
         raise HTTPException(status_code=404, detail=f"Printer not found: {', '.join(map(str, missing))}")
+    leaving = {location for _, location in rows if location != data.location}
+    grantees = await location_grantees(db, [*leaving, data.location]) if leaving else []
+    await _ensure_admin_for_access_change(db, user, grantees)
     result = await db.execute(update(Printer).where(Printer.id.in_(ids)).values(location=data.location))
     await db.commit()
+    if grantees:
+        await ws_manager.refresh_printer_scopes()
     await _broadcast()
     return PrinterLocationAssignResult(moved=result.rowcount or 0)

+ 3 - 3
backend/app/api/routes/printer_sensor_history.py

@@ -7,7 +7,7 @@ from pydantic import BaseModel
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequirePrinterPermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
@@ -46,7 +46,7 @@ async def get_printer_sensor_history(
         description="Comma-separated list of sensor kinds (nozzle, nozzle_2, bed, chamber). All by default.",
     ),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
 ):
     """Return per-sensor heater history for a printer."""
     since = datetime.now(timezone.utc) - timedelta(hours=hours)
@@ -112,7 +112,7 @@ async def delete_old_history(
     printer_id: int,
     days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
 ):
     """Delete old printer sensor history for a printer."""
     cutoff = datetime.now(timezone.utc) - timedelta(days=days)

+ 93 - 62
backend/app/api/routes/printers.py

@@ -13,17 +13,19 @@ from starlette.background import BackgroundTask
 
 from backend.app.core import database
 from backend.app.core.auth import (
+    RequestPrinterScope,
     RequireOverlayTokenIfAuthEnabled,
     RequirePermissionIfAuthEnabled,
     RequirePrinterPermissionIfAuthEnabled,
     is_auth_enabled,
-    require_media_token_permission,
     require_media_token_printer_permission,
 )
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope, location_grantees
 from backend.app.core.tasks import spawn_background_task
+from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.slot_preset import SlotPresetMapping
@@ -139,15 +141,19 @@ def _serialize_printer(printer: Printer, *, include_secret: bool):
 @router.get("/")
 async def list_printers(
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
 ):
-    """List all configured printers.
+    """List the configured printers the caller may see (#1727).
 
     ``access_code`` is included in each item only when the caller is trusted
     to see it (Admin / Operator JWT, or auth-disabled mode). Viewers and
     API keys never receive it.
     """
-    result = await db.execute(select(Printer).order_by(Printer.name))
+    query = select(Printer).order_by(Printer.name)
+    if (clause := printer_scope.where_strict(Printer.id)) is not None:
+        query = query.where(clause)
+    result = await db.execute(query)
     printers = list(result.scalars().all())
     include_secret = await _caller_can_view_printer_secrets(user, db)
     return [_serialize_printer(p, include_secret=include_secret) for p in printers]
@@ -197,6 +203,10 @@ async def create_printer(
     await db.commit()
     await db.refresh(printer)
 
+    # A group given this location reaches the new printer straight away (#1727)
+    if await location_grantees(db, [printer.location]):
+        await ws_manager.refresh_printer_scopes()
+
     # Connect to the printer
     if printer.is_active:
         await printer_manager.connect_printer(printer)
@@ -227,6 +237,7 @@ async def get_available_filaments(
     model: str = Query(..., description="Target printer model"),
     location: str | None = Query(None, description="Optional location filter"),
     _=RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
 ):
     """Get deduplicated list of filaments loaded across all active printers of a given model.
@@ -245,7 +256,7 @@ async def get_available_filaments(
         query = query.where(Printer.location == location)
 
     result = await db.execute(query)
-    printers_list = list(result.scalars().all())
+    printers_list = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
     if not printers_list:
         return []
@@ -330,11 +341,12 @@ async def get_available_filaments(
 @router.get("/developer-mode-warnings")
 async def get_developer_mode_warnings(
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
 ):
-    """Check if any connected printer lacks developer LAN mode."""
+    """Check if any connected printer the caller can see lacks developer LAN mode."""
     result = await db.execute(select(Printer).where(Printer.is_active == True))  # noqa: E712
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
     statuses = printer_manager.get_all_statuses()
 
     warnings = []
@@ -353,7 +365,7 @@ async def get_developer_mode_warnings(
 @router.get("/{printer_id}")
 async def get_printer(
     printer_id: int,
-    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get a specific printer.
@@ -374,7 +386,7 @@ async def get_printer(
 async def update_printer(
     printer_id: int,
     printer_data: PrinterUpdate,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
     """Update a printer."""
@@ -385,6 +397,18 @@ async def update_printer(
 
     update_data = printer_data.model_dump(exclude_unset=True)
 
+    # Groups can be given a location (#1727), so moving a printer between
+    # locations changes who can reach it. That is an access change and only an
+    # admin may make it; an API key never counts as one.
+    access_moved = False
+    if "location" in update_data and update_data["location"] != printer.location:
+        access_moved = bool(await location_grantees(db, [printer.location, update_data["location"]]))
+        if access_moved and await is_auth_enabled(db) and not (user is not None and user.is_admin):
+            raise HTTPException(
+                403,
+                "Moving this printer to another location changes which groups can access it. Only an admin can do that.",
+            )
+
     # Handle nested ROI object - flatten to individual columns
     if "plate_detection_roi" in update_data:
         roi = update_data.pop("plate_detection_roi")
@@ -406,6 +430,9 @@ async def update_printer(
     await db.commit()
     await db.refresh(printer)
 
+    if access_moved:
+        await ws_manager.refresh_printer_scopes()
+
     # Reconnect if connection settings changed
     if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
         printer_manager.disconnect_printer(printer_id)
@@ -419,7 +446,7 @@ async def update_printer(
 async def delete_printer(
     printer_id: int,
     delete_archives: bool = True,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
     db: AsyncSession = Depends(get_db),
 ):
     """Delete a printer.
@@ -432,6 +459,7 @@ async def delete_printer(
     from sqlalchemy import delete as sql_delete
 
     from backend.app.models.archive import PrintArchive
+    from backend.app.models.group import group_printers
     from backend.app.models.maintenance import MaintenanceHistory, PrinterMaintenance
     from backend.app.models.scheduled_drying import ScheduledDrying
     from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
@@ -458,6 +486,9 @@ async def delete_printer(
     # Delete scheduled drying runs for this printer (SQLite doesn't enforce FK cascades)
     await db.execute(sql_delete(ScheduledDrying).where(ScheduledDrying.printer_id == printer_id))
 
+    # Drop it from printer-scoped groups (SQLite doesn't enforce FK cascades)
+    await db.execute(sql_delete(group_printers).where(group_printers.c.printer_id == printer_id))
+
     # Delete maintenance history and items for this printer
     # (SQLite doesn't enforce FK cascades, so do it explicitly)
     maintenance_ids = (
@@ -480,7 +511,7 @@ async def delete_printer(
 @router.get("/{printer_id}/status", response_model=PrinterStatus)
 async def get_printer_status(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get real-time status of a printer."""
@@ -934,7 +965,7 @@ async def get_overlay_status(
 @router.get("/{printer_id}/current-print-user")
 async def get_current_print_user(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get the user who started the current print (for reprint tracking).
@@ -955,7 +986,7 @@ async def get_current_print_user(
 @router.post("/{printer_id}/refresh-status")
 async def refresh_printer_status(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Request a full status refresh from the printer (sends pushall command)."""
@@ -974,7 +1005,7 @@ async def refresh_printer_status(
 @router.post("/{printer_id}/connect")
 async def connect_printer(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Manually connect to a printer."""
@@ -990,7 +1021,7 @@ async def connect_printer(
 @router.post("/{printer_id}/disconnect")
 async def disconnect_printer(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Manually disconnect from a printer."""
@@ -1039,7 +1070,7 @@ async def diagnose_connection(
 @router.get("/{printer_id}/diagnostic", response_model=PrinterDiagnosticResult)
 async def diagnose_printer(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Run connection diagnostics for an existing saved printer.
@@ -1129,7 +1160,7 @@ async def _running_print_archive_file(printer_id: int, state) -> Path | None:
 async def get_printer_cover(
     printer_id: int,
     view: str | None = None,
-    _: User | None = Depends(require_media_token_permission(Permission.PRINTERS_READ)),
+    _: User | None = Depends(require_media_token_printer_permission(Permission.PRINTERS_READ)),
 ):
     """Get the cover image for the current print job.
 
@@ -2138,7 +2169,7 @@ async def delete_printer_file(
 @router.get("/{printer_id}/storage")
 async def get_printer_storage(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
 ):
     """Get storage information from the printer."""
     printer = await _load_printer_or_404(printer_id)
@@ -2156,7 +2187,7 @@ async def get_printer_storage(
 @router.post("/{printer_id}/logging/enable")
 async def enable_mqtt_logging(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Enable MQTT message logging for a printer."""
@@ -2175,7 +2206,7 @@ async def enable_mqtt_logging(
 @router.post("/{printer_id}/logging/disable")
 async def disable_mqtt_logging(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Disable MQTT message logging for a printer."""
@@ -2194,7 +2225,7 @@ async def disable_mqtt_logging(
 @router.get("/{printer_id}/logging")
 async def get_mqtt_logs(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get MQTT message logs for a printer."""
@@ -2221,7 +2252,7 @@ async def get_mqtt_logs(
 @router.delete("/{printer_id}/logging")
 async def clear_mqtt_logs(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Clear MQTT message logs for a printer."""
@@ -2252,7 +2283,7 @@ async def start_drying(
     duration: int = 4,
     filament: str = "",
     rotate_tray: bool = False,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Send AMS drying start command. temp=45-85, duration=hours."""
@@ -2300,7 +2331,7 @@ async def start_drying(
 async def stop_drying(
     printer_id: int,
     ams_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Send AMS drying stop command."""
@@ -2341,7 +2372,7 @@ async def set_print_option(
     enabled: bool,
     print_halt: bool = True,
     sensitivity: str = "medium",
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Set an AI detection / print option on the printer.
@@ -2405,7 +2436,7 @@ async def set_print_option(
 async def set_ams_backup(
     printer_id: int,
     enabled: bool,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out)."""
@@ -2428,7 +2459,7 @@ async def set_ams_backup(
 @router.get("/{printer_id}/inventory-remain")
 async def get_inventory_remain(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Per-globalTrayId remaining grams for slots bound to an inventory spool.
@@ -2478,7 +2509,7 @@ async def start_calibration(
     motor_noise: bool = False,
     nozzle_offset: bool = False,
     high_temp_heatbed: bool = False,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Start printer calibration with selected options.
@@ -2543,7 +2574,7 @@ def _slot_preset_key(ams_id: int, tray_id: int) -> int:
 @router.get("/{printer_id}/slot-presets")
 async def get_slot_presets(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get all saved slot-to-preset mappings for a printer."""
@@ -2567,7 +2598,7 @@ async def get_slot_preset(
     printer_id: int,
     ams_id: int,
     tray_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get the saved preset for a specific slot."""
@@ -2601,7 +2632,7 @@ async def save_slot_preset(
     preset_name: str,
     preset_source: str = "cloud",
     tray_info_idx: str | None = None,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
     """Save a preset mapping for a specific slot.
@@ -2666,7 +2697,7 @@ async def delete_slot_preset(
     printer_id: int,
     ams_id: int,
     tray_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
     """Delete a saved preset mapping for a slot."""
@@ -2692,7 +2723,7 @@ async def get_slot_spool_defaults(
     ams_id: int,
     tray_id: int,
     db: AsyncSession = Depends(get_db),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
 ):
     """What the spool assigned to this slot is configured to use here.
 
@@ -2845,7 +2876,7 @@ async def configure_ams_slot(
     k_value: float = Query(0.0),
     orca_profile_id: str = Query(""),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    current_user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """Configure an AMS slot with a specific filament setting and K profile.
 
@@ -3361,7 +3392,7 @@ async def reset_ams_slot(
     ams_id: int,
     tray_id: int,
     db: AsyncSession = Depends(get_db),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """Reset an AMS slot to empty/unconfigured state.
 
@@ -3403,7 +3434,7 @@ async def reset_ams_slot(
 @router.get("/{printer_id}/ams-labels")
 async def get_ams_labels(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get all user-defined AMS labels for a printer, keyed by AMS unit ID.
@@ -3458,7 +3489,7 @@ async def save_ams_label(
     printer_id: int,
     ams_id: int,
     body: AmsLabelBody,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
     """Create or update the friendly name for a specific AMS unit.
@@ -3495,7 +3526,7 @@ async def delete_ams_label(
     printer_id: int,
     ams_id: int,
     ams_serial: str = Query(default="", max_length=50),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
     """Delete the friendly name for a specific AMS unit, reverting to the auto label."""
@@ -3516,7 +3547,7 @@ async def delete_ams_label(
 async def debug_simulate_print_complete(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """DEBUG: Simulate print completion to test freeze behavior.
 
@@ -3568,7 +3599,7 @@ async def debug_simulate_print_complete(
 @router.post("/{printer_id}/print/stop")
 async def stop_print(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Stop/cancel the current print job."""
@@ -3602,7 +3633,7 @@ async def stop_print(
 @router.post("/{printer_id}/clear-plate")
 async def clear_plate(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CLEAR_PLATE),
     db: AsyncSession = Depends(get_db),
 ):
     """Acknowledge that the build plate has been cleared after a finished/failed print.
@@ -3657,7 +3688,7 @@ async def clear_plate(
 @router.post("/{printer_id}/print/pause")
 async def pause_print(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Pause the current print job."""
@@ -3680,7 +3711,7 @@ async def pause_print(
 @router.post("/{printer_id}/print/resume")
 async def resume_print(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Resume a paused print job."""
@@ -3704,7 +3735,7 @@ async def resume_print(
 async def set_print_speed(
     printer_id: int,
     mode: int = Query(..., description="Speed mode (1=silent, 2=standard, 3=sport, 4=ludicrous)"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Set the print speed mode."""
@@ -3730,7 +3761,7 @@ async def set_nozzle_temperature(
     printer_id: int,
     target: int = Query(..., ge=0, le=320, description="Target nozzle temperature in Celsius; 0 turns heating off"),
     nozzle: int = Query(0, ge=0, le=1, description="Nozzle/extruder index (0=right/default, 1=left)"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Set a nozzle target temperature."""
@@ -3754,7 +3785,7 @@ async def set_nozzle_temperature(
 async def set_bed_temperature(
     printer_id: int,
     target: int = Query(..., ge=0, le=140, description="Target bed temperature in Celsius; 0 turns heating off"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Set the bed target temperature."""
@@ -3783,7 +3814,7 @@ async def set_chamber_temperature(
         le=MAX_CHAMBER_TEMP_C,
         description="Target chamber temperature in Celsius; 0 turns heating off",
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Set the chamber target temperature.
@@ -3817,7 +3848,7 @@ async def set_fan_speed(
     printer_id: int,
     fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
     speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Set a fan speed by percentage.
@@ -3879,7 +3910,7 @@ async def set_fan_speed(
 async def select_extruder(
     printer_id: int,
     extruder: int = Query(..., ge=0, le=1, description="Extruder index (0=right, 1=left)"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Select the active extruder/nozzle on dual-nozzle printers."""
@@ -3903,7 +3934,7 @@ async def select_extruder(
 async def set_airduct_mode(
     printer_id: int,
     mode: str = Query(..., description="Airduct mode: 'cooling' or 'heating'"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Set the airduct mode (cooling/heating) on supported printers (P2S/H2*)."""
@@ -3930,7 +3961,7 @@ async def set_airduct_mode(
 async def set_chamber_light(
     printer_id: int,
     on: bool = Query(..., description="True to turn on, False to turn off"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Turn the chamber light on or off."""
@@ -3966,7 +3997,7 @@ async def bed_jog(
             "or is needed."
         ),
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Adjust the nozzle-bed gap by a relative distance.
@@ -4045,7 +4076,7 @@ async def xy_jog(
     printer_id: int,
     x: float = Query(0, description="Signed relative X movement in mm"),
     y: float = Query(0, description="Signed relative Y movement in mm"),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Move the toolhead by a relative X/Y distance."""
@@ -4082,7 +4113,7 @@ async def extruder_jog(
     distance: float = Query(
         ..., description="Signed relative extrusion distance in mm. Positive extrudes, negative retracts."
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Extrude or retract filament by a relative distance.
@@ -4116,7 +4147,7 @@ async def home_axes(
         "all",
         description="Legacy; accepted values are 'z' | 'xy' | 'all'. Always runs the printer's full auto-home sequence — see below.",
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Run the printer's full auto-home sequence via bare `G28`.
@@ -4156,7 +4187,7 @@ async def home_axes(
 @router.post("/{printer_id}/hms/clear")
 async def clear_hms_errors(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Clear HMS/print errors on the printer."""
@@ -4180,7 +4211,7 @@ async def clear_hms_errors(
 async def get_printable_objects(
     printer_id: int,
     reload: bool = False,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Get the list of printable objects for the current print.
@@ -4339,7 +4370,7 @@ async def get_printable_objects(
 async def skip_objects(
     printer_id: int,
     object_ids: list[int],
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Skip specific objects during the current print.
@@ -4394,7 +4425,7 @@ async def refresh_ams_slot(
     printer_id: int,
     ams_id: int,
     slot_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_AMS_RFID),
     db: AsyncSession = Depends(get_db),
 ):
     """Re-read RFID for an AMS slot (triggers filament info refresh)."""
@@ -4663,7 +4694,7 @@ async def ams_load(
             "— the field is absent from BambuStudio's own command there too."
         ),
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Load filament from a specific AMS slot or external spool.
@@ -4711,7 +4742,7 @@ async def ams_unload(
             "names, which is the only option a single-nozzle printer has."
         ),
     ),
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Unload the filament in a given slot, or the currently loaded one."""
@@ -4742,7 +4773,7 @@ async def ams_unload(
 @router.get("/{printer_id}/runtime-debug")
 async def get_runtime_debug(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
     """Debug endpoint: Get runtime tracking status for a printer."""
@@ -4777,7 +4808,7 @@ async def get_runtime_debug(
 async def execute_hms_action(
     printer_id: int,
     body: HmsActionBody,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):
     """Execute an HMS action on the printer."""

+ 9 - 1
backend/app/api/routes/projects.py

@@ -16,10 +16,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.library import get_library_dir
-from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_media_token_permission
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled, require_media_token_permission
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_log import PrintLogEntry
@@ -916,6 +917,7 @@ async def list_project_archives(
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """List archives in a project."""
     # Verify project exists
@@ -937,6 +939,9 @@ async def list_project_archives(
         .limit(limit)
         .offset(offset)
     )
+    # Only archives from printers the caller may see (#1727)
+    if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        query = query.where(clause)
     result = await db.execute(query)
     archives = result.scalars().all()
 
@@ -955,6 +960,7 @@ async def list_project_queue(
     project_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """List queue items in a project."""
     # Verify project exists
@@ -964,6 +970,8 @@ async def list_project_queue(
 
     # Get queue items
     query = select(PrintQueueItem).where(PrintQueueItem.project_id == project_id).order_by(PrintQueueItem.position)
+    if (clause := printer_scope.where(PrintQueueItem.printer_id)) is not None:
+        query = query.where(clause)
     result = await db.execute(query)
     items = result.scalars().all()
 

+ 9 - 2
backend/app/api/routes/scheduled_dryings.py

@@ -4,9 +4,10 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.scheduled_drying import ScheduledDrying
 from backend.app.models.user import User
@@ -29,8 +30,10 @@ LISTED_STATUSES = (*ACTIVE_STATUSES, "failed")
 async def create_scheduled_drying(
     payload: ScheduledDryingCreate,
     user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
 ):
+    printer_scope.ensure(payload.printer_id)
     result = await db.execute(select(Printer).where(Printer.id == payload.printer_id))
     printer = result.scalar_one_or_none()
     if not printer:
@@ -70,9 +73,12 @@ async def create_scheduled_drying(
 async def list_scheduled_dryings(
     printer_id: int | None = None,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
 ):
     query = select(ScheduledDrying).where(ScheduledDrying.status.in_(LISTED_STATUSES))
+    if (clause := printer_scope.where_strict(ScheduledDrying.printer_id)) is not None:
+        query = query.where(clause)
     if printer_id is not None:
         query = query.where(ScheduledDrying.printer_id == printer_id)
     result = await db.execute(query.order_by(ScheduledDrying.start_after.asc().nullsfirst(), ScheduledDrying.id.asc()))
@@ -83,11 +89,12 @@ async def list_scheduled_dryings(
 async def cancel_scheduled_drying(
     scheduled_drying_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    printer_scope: PrinterScope = RequestPrinterScope,
     db: AsyncSession = Depends(get_db),
 ):
     result = await db.execute(select(ScheduledDrying).where(ScheduledDrying.id == scheduled_drying_id))
     row = result.scalar_one_or_none()
-    if not row:
+    if not row or not printer_scope.allows(row.printer_id):
         raise HTTPException(404, "Scheduled drying not found")
     if row.status == "failed":
         # Terminal and now acknowledged; drop it so it stops being listed.

+ 27 - 10
backend/app/api/routes/smart_plugs.py

@@ -9,9 +9,14 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.api.routes.settings import get_setting
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
@@ -51,9 +56,13 @@ router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
 async def list_smart_plugs(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
-    """List all smart plugs."""
-    result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
+    """List all smart plugs, minus those powering printers the caller can't see (#1727)."""
+    query = select(SmartPlug).order_by(SmartPlug.name)
+    if (clause := printer_scope.where(SmartPlug.printer_id)) is not None:
+        query = query.where(clause)
+    result = await db.execute(query)
     return list(result.scalars().all())
 
 
@@ -62,10 +71,12 @@ async def create_smart_plug(
     data: SmartPlugCreate,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Create a new smart plug."""
     # Validate printer_id if provided
     if data.printer_id:
+        printer_scope.ensure(data.printer_id)
         result = await db.execute(select(Printer).where(Printer.id == data.printer_id))
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
@@ -226,7 +237,7 @@ async def _plugs_for_printer(db: AsyncSession, printer_id: int) -> list[SmartPlu
 async def get_smart_plug_by_printer(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
     """Get the main smart plug assigned to a printer.
 
@@ -241,7 +252,7 @@ async def get_smart_plug_by_printer(
 async def get_script_plugs_by_printer(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
     """Get all HA entities assigned to a printer for display on printer card.
 
@@ -481,11 +492,12 @@ async def get_smart_plug(
     plug_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get a specific smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
     return plug
 
@@ -496,11 +508,12 @@ async def update_smart_plug(
     data: SmartPlugUpdate,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Update a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
 
     update_data = data.model_dump(exclude_unset=True)
@@ -508,6 +521,7 @@ async def update_smart_plug(
     # Validate new printer_id if being changed
     if "printer_id" in update_data and update_data["printer_id"]:
         new_printer_id = update_data["printer_id"]
+        printer_scope.ensure(new_printer_id)
 
         # Check printer exists
         result = await db.execute(select(Printer).where(Printer.id == new_printer_id))
@@ -587,11 +601,12 @@ async def delete_smart_plug(
     plug_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Delete a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
 
     plug_name = plug.name
@@ -631,11 +646,12 @@ async def control_smart_plug(
     control: SmartPlugControl,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Manual control: on/off/toggle."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
 
     # MQTT plugs are monitor-only - cannot control them
@@ -742,11 +758,12 @@ async def get_plug_status(
     plug_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Get current plug status from device including energy data."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
-    if not plug:
+    if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
 
     # Handle MQTT plugs - get data from subscription service

+ 13 - 3
backend/app/api/routes/spoolman.py

@@ -12,9 +12,14 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes._spoolman_helpers import _map_spoolman_spool, spoolman_net_weight
 from backend.app.api.routes.spoolman_inventory import _clear_stale_tag_links
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequestPrinterScope,
+    RequirePermissionIfAuthEnabled,
+    RequirePrinterPermissionIfAuthEnabled,
+)
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.spool_assignment import SpoolAssignment
@@ -213,7 +218,7 @@ async def disconnect_spoolman(
 async def sync_printer_ams(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
     """Sync AMS data from a specific printer to Spoolman."""
     # Check if Spoolman is enabled and connected
@@ -449,6 +454,7 @@ async def sync_printer_ams(
 async def sync_all_printers(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Sync AMS data from all connected printers to Spoolman."""
     # Check if Spoolman is enabled
@@ -470,7 +476,7 @@ async def sync_all_printers(
 
     # Get all active printers
     result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
-    printers = result.scalars().all()
+    printers = [p for p in result.scalars().all() if printer_scope.allows(p.id)]
 
     total_synced = 0
     all_skipped: list[SkippedSpool] = []
@@ -834,6 +840,7 @@ async def link_spool(
     request: LinkSpoolRequest,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Link a Spoolman spool to an AMS tag by setting Spoolman extra.tag."""
     sm = await get_spoolman_settings(db)
@@ -871,6 +878,7 @@ async def link_spool(
     # that field is user-managed in Spoolman. Slot assignment is stored locally.
     printer_context: tuple[int, int, int] | None = None
     if request.printer_id is not None and request.ams_id is not None and request.tray_id is not None:
+        printer_scope.ensure(request.printer_id)
         printer_result = await db.execute(select(Printer).where(Printer.id == request.printer_id))
         if not printer_result.scalar_one_or_none():
             raise HTTPException(status_code=404, detail="Printer not found")
@@ -1229,6 +1237,7 @@ async def create_spool_from_slot(
     req: CreateSpoolFromSlotRequest,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Explicit user action: create a Spoolman spool from an AMS slot's current tray data.
 
@@ -1250,6 +1259,7 @@ async def create_spool_from_slot(
     if not await client.health_check():
         raise HTTPException(status_code=503, detail="Spoolman is not reachable")
 
+    printer_scope.ensure(req.printer_id)
     result = await db.execute(select(Printer).where(Printer.id == req.printer_id))
     printer = result.scalar_one_or_none()
     if not printer:

+ 18 - 5
backend/app/api/routes/spoolman_inventory.py

@@ -35,9 +35,10 @@ from backend.app.api.routes._spoolman_helpers import (
     spoolman_price_weight,
     spoolman_tare,
 )
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.auth import RequestPrinterScope, RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
@@ -1330,6 +1331,7 @@ async def get_all_spoolman_slot_assignments(
     printer_id: int | None = Query(None, gt=0),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ) -> list[SpoolmanSlotAssignmentEnriched]:
     """Return all Spoolman slot assignments enriched with printer_name and ams_label.
 
@@ -1341,6 +1343,8 @@ async def get_all_spoolman_slot_assignments(
     query = select(SpoolmanSlotAssignment).options(selectinload(SpoolmanSlotAssignment.printer))
     if printer_id is not None:
         query = query.where(SpoolmanSlotAssignment.printer_id == printer_id)
+    if (clause := printer_scope.where_strict(SpoolmanSlotAssignment.printer_id)) is not None:
+        query = query.where(clause)
     result = await db.execute(query)
     slots = list(result.scalars().all())
 
@@ -1421,6 +1425,7 @@ async def get_all_spoolman_slot_assignments(
 async def sync_spoolman_ams_weights(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
     """Sync remaining weight back to Spoolman for all slot-assigned spools.
 
@@ -1435,7 +1440,8 @@ async def sync_spoolman_ams_weights(
     spool_lookup: dict[int, dict] = {s["id"]: s for s in raw_spools if s.get("id") is not None}
 
     result = await db.execute(select(SpoolmanSlotAssignment))
-    assignments = list(result.scalars().all())
+    # Only slots on printers the caller may see (#1727)
+    assignments = [a for a in result.scalars().all() if printer_scope.allows(a.printer_id)]
 
     synced = 0
     skipped = 0
@@ -1549,6 +1555,7 @@ async def assign_spoolman_slot(
     body: SpoolSlotAssignmentRequest,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ) -> dict:
     """Assign a Spoolman spool to a printer AMS slot (stored in local DB only).
 
@@ -1557,6 +1564,7 @@ async def assign_spoolman_slot(
     """
 
     client = await _get_client(db)
+    printer_scope.ensure(body.printer_id)
     result = await db.execute(select(Printer).where(Printer.id == body.printer_id))
     printer = result.scalar_one_or_none()
     if not printer:
@@ -1865,6 +1873,7 @@ async def unassign_spoolman_slot(
     spoolman_spool_id: int = Path(..., gt=0),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ) -> dict:
     """Remove the local slot assignment for a Spoolman spool.
 
@@ -1873,9 +1882,11 @@ async def unassign_spoolman_slot(
     client = await _get_client(db)
 
     try:
-        await db.execute(
-            delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spoolman_spool_id)
-        )
+        # A slot on a printer the caller can't see stays assigned (#1727)
+        unassign = delete(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.spoolman_spool_id == spoolman_spool_id)
+        if (clause := printer_scope.where_strict(SpoolmanSlotAssignment.printer_id)) is not None:
+            unassign = unassign.where(clause)
+        await db.execute(unassign)
         await db.commit()
     except Exception as exc:
         await db.rollback()
@@ -1902,9 +1913,11 @@ async def get_spoolman_slot_assignment(
     tray_id: int = Query(..., ge=0, le=3),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ) -> dict | None:
     """Return the Spoolman spool assigned to a specific printer slot, or null if unassigned."""
     client = await _get_client(db)
+    printer_scope.ensure(printer_id)
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     if not printer:

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

@@ -23,6 +23,7 @@ from backend.app.core.auth import (
 )
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
@@ -349,6 +350,10 @@ async def update_user(
     await ensure_user_finance_defaults(db, user)
 
     await db.commit()
+    if user_data.group_ids is not None or user_data.role is not None or user_data.is_active is not None:
+        # Groups, admin role and deactivation all change which printers the
+        # user's open dashboards may hear about (#1727)
+        await ws_manager.refresh_printer_scopes()
     result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one()
 
@@ -467,6 +472,22 @@ async def delete_user(
         # users would otherwise leave dangling created_by_id on SQLite (#1295 review nit).
         from sqlalchemy import update
 
+        from backend.app.core.printer_scope import resolve_user_printer_scope
+
+        # An ownerless "any <model>" job may run on any printer of that model;
+        # the scheduler held it to this user's printers only while it was
+        # theirs (#1727). Stage those jobs instead, so an admin decides where
+        # they run rather than the job quietly spreading across the fleet.
+        if not (await resolve_user_printer_scope(db, user)).is_unrestricted:
+            await db.execute(
+                update(PrintQueueItem)
+                .where(
+                    PrintQueueItem.created_by_id == user_id,
+                    PrintQueueItem.printer_id.is_(None),
+                    PrintQueueItem.status == "pending",
+                )
+                .values(manual_start=True)
+            )
         await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
         await db.execute(
             update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
@@ -517,6 +538,7 @@ async def delete_user(
 
     await db.delete(user)
     await db.commit()
+    await ws_manager.refresh_printer_scopes()
 
     for file_id in doomed_library_file_ids:
         remove_library_photos_dir(file_id)

+ 32 - 10
backend/app/api/routes/webhook.py

@@ -5,7 +5,15 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import check_printer_access, check_webhook_permission, get_api_key
+from backend.app.core.auth import (
+    api_key_printer_scope,
+    check_webhook_permission,
+    ensure_api_key_printer_access,
+    get_api_key,
+    is_auth_enabled,
+    queue_review_required_for,
+    resolve_apikey_owner,
+)
 from backend.app.core.database import get_db
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
@@ -101,7 +109,7 @@ async def webhook_add_to_queue(
     Requires 'can_queue' permission.
     """
     await check_webhook_permission(db, api_key, "queue")
-    check_printer_access(api_key, data.printer_id)
+    await ensure_api_key_printer_access(db, api_key, data.printer_id)
 
     # Verify archive exists
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == data.archive_id))
@@ -145,6 +153,8 @@ async def webhook_add_to_queue(
         # for the person whose key it is. Legacy keys predating per-user ownership
         # have no `user_id`, and those rows stay ownerless.
         created_by_id=api_key.user_id,
+        # Waits for someone to start it unless the owner may print without review (#1620)
+        manual_start=await is_auth_enabled(db) and queue_review_required_for(await resolve_apikey_owner(db, api_key)),
     )
     db.add(queue_item)
     await db.flush()
@@ -180,7 +190,7 @@ async def webhook_start_print(
     Requires 'can_control_printer' permission.
     """
     await check_webhook_permission(db, api_key, "control_printer")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -202,6 +212,18 @@ async def webhook_start_print(
     if not queue_item:
         raise HTTPException(status_code=404, detail="No pending prints in queue")
 
+    # Starting a waiting job is a review decision (#1620): a key whose owner
+    # needs review for their own jobs can't make one for anybody's
+    if (
+        queue_item.manual_start
+        and await is_auth_enabled(db)
+        and queue_review_required_for(await resolve_apikey_owner(db, api_key))
+    ):
+        raise HTTPException(
+            status_code=403,
+            detail="The next job waits for review: someone who can manage all queue jobs has to start it",
+        )
+
     # Clear manual_start so the scheduler will dispatch. If the item was
     # already auto-dispatchable this is a no-op; the scheduler will still
     # pick it up on its next tick.
@@ -224,7 +246,7 @@ async def webhook_stop_print(
     Requires 'can_control_printer' permission.
     """
     await check_webhook_permission(db, api_key, "control_printer")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
     status = printer_manager.get_status(printer_id)
     # `printer_manager.get_status(...)` returns a ``PrinterState`` dataclass
@@ -256,7 +278,7 @@ async def webhook_cancel_print(
     Requires 'can_control_printer' permission.
     """
     await check_webhook_permission(db, api_key, "control_printer")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
     status = printer_manager.get_status(printer_id)
     # Same dataclass-not-dict shape as stop_print above (#1584).
@@ -286,7 +308,7 @@ async def webhook_get_printer_status(
     Requires 'can_read_status' permission.
     """
     await check_webhook_permission(db, api_key, "read_status")
-    check_printer_access(api_key, printer_id)
+    await ensure_api_key_printer_access(db, api_key, printer_id)
 
     # Get printer
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -342,15 +364,15 @@ async def webhook_get_queue_status(
 
     # Get printers
     if printer_id:
-        check_printer_access(api_key, printer_id)
+        await ensure_api_key_printer_access(db, api_key, printer_id)
         result = await db.execute(select(Printer).where(Printer.id == printer_id))
         printers = result.scalars().all()
     else:
         result = await db.execute(select(Printer))
         printers = result.scalars().all()
-        # Filter by allowed printers if limited
-        if api_key.printer_ids is not None:
-            printers = [p for p in printers if p.id in api_key.printer_ids]
+        # Only the printers the key (and its owner) may reach
+        scope = await api_key_printer_scope(db, api_key)
+        printers = [p for p in printers if scope.allows(p.id)]
 
     response = []
     for printer in printers:

+ 28 - 5
backend/app/api/routes/websocket.py

@@ -21,8 +21,9 @@ import logging
 from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 from sqlalchemy import select
 
-from backend.app.core.auth import is_auth_enabled, verify_websocket_token
+from backend.app.core.auth import is_auth_enabled, principal_printer_scope, verify_websocket_token_principal
 from backend.app.core.database import async_session
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
 from backend.app.core.websocket import ws_manager
 from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
@@ -67,19 +68,37 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
         return
 
     principal: str | None = None
+    api_key_id: int | None = None
+    printer_scope: PrinterScope = ALL_PRINTERS
     if auth_required:
         if not token:
             logger.info("WebSocket connect refused: no token (auth enabled)")
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             return
-        principal = await verify_websocket_token(token)
-        if principal is None:
+        token_principal = await verify_websocket_token_principal(token)
+        if token_principal is None:
             logger.info("WebSocket connect refused: invalid or expired token")
             await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
             return
+        principal, api_key_id = token_principal
+        # Which printers this socket may hear about (#1727). Fail-closed: if
+        # it can't be worked out the socket is refused rather than admitted
+        # with every printer.
+        try:
+            async with async_session() as db:
+                printer_scope = await principal_printer_scope(db, principal, api_key_id)
+        except Exception:  # SEC-AUTH-EXC: scope lookup failure → refuse connect (fail-closed)
+            logger.error("WebSocket printer scope lookup failed; refusing connection", exc_info=True)
+            await websocket.close(code=_WS_CLOSE_UNAUTHORIZED)
+            return
 
     # Token verified (or auth disabled); now safe to admit the connection.
     logger.info("WebSocket client connecting (principal=%s)", principal if principal else "<anonymous>")
+    # Stamped before connect() puts the socket in the broadcast list, so no
+    # broadcast can reach it unfiltered (ws_manager refuses printer-bound
+    # messages to a socket without a scope anyway).
+    websocket.state.bambuddy_printer_scope = printer_scope
+    websocket.state.bambuddy_scope_principal = (principal, api_key_id)
     await ws_manager.connect(websocket)
     # Stash on connection state for any future per-message permission
     # logic; today the message handlers are read-only and only respond
@@ -106,7 +125,11 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
 
     try:
         # Send initial status of all printers.
-        statuses = printer_manager.get_all_statuses()
+        statuses = {
+            pid: state
+            for pid, state in printer_manager.get_all_statuses().items()
+            if websocket.state.bambuddy_printer_scope.allows(pid)
+        }
         for printer_id, state in statuses.items():
             await websocket.send_json(
                 {
@@ -134,7 +157,7 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
             # Handle status request
             elif data.get("type") == "get_status":
                 printer_id = data.get("printer_id")
-                if printer_id:
+                if printer_id and websocket.state.bambuddy_printer_scope.allows(printer_id):
                     state = printer_manager.get_status(printer_id)
                     if state:
                         await websocket.send_json(

+ 311 - 65
backend/app/core/auth.py

@@ -20,6 +20,13 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.printer_scope import (
+    ALL_PRINTERS,
+    PrinterScope,
+    api_key_own_scope,
+    resolve_user_id_printer_scope,
+    resolve_user_printer_scope,
+)
 from backend.app.models.api_key import APIKey
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
 from backend.app.models.settings import Settings
@@ -135,6 +142,7 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str | tuple[str, ...]] = {
     Permission.QUEUE_DELETE_OWN: "can_queue",
     Permission.QUEUE_DELETE_ALL: "can_queue",
     Permission.QUEUE_REORDER: "can_queue",
+    Permission.QUEUE_START_UNREVIEWED: "can_queue",
     Permission.ARCHIVES_REPRINT_OWN: "can_queue",
     Permission.ARCHIVES_REPRINT_ALL: "can_queue",
     # can_control_printer — physical-world side effects on hardware
@@ -815,8 +823,13 @@ async def verify_slicer_download_token(
 CAMERA_STREAM_TOKEN_EXPIRE_MINUTES = 60
 
 
-async def create_camera_stream_token() -> str:
-    """Create a reusable token for camera stream/snapshot access."""
+async def create_camera_stream_token(username: str | None = None, api_key_id: int | None = None) -> str:
+    """Create a reusable token for camera stream/snapshot access.
+
+    Records who minted it -- ``username`` for a user, ``api_key_id`` for an
+    API key -- so the streams it opens stay within that caller's printer scope
+    (#1727). Neither is set when auth is off.
+    """
     now = datetime.now(timezone.utc)
     expires_at = now + timedelta(minutes=CAMERA_STREAM_TOKEN_EXPIRE_MINUTES)
     token = secrets.token_urlsafe(24)
@@ -832,6 +845,8 @@ async def create_camera_stream_token() -> str:
             AuthEphemeralToken(
                 token=token,
                 token_type="camera_stream",
+                username=username or "",
+                api_key_id=api_key_id,
                 expires_at=expires_at,
             )
         )
@@ -842,7 +857,7 @@ async def create_camera_stream_token() -> str:
 WEBSOCKET_TOKEN_EXPIRE_MINUTES = 60
 
 
-async def create_websocket_token(username: str | None) -> str:
+async def create_websocket_token(username: str | None, api_key_id: int | None = None) -> str:
     """Create a short-lived token for ``/api/v1/ws`` connections.
 
     Mirrors the camera-stream-token pattern: opaque random string stored
@@ -874,6 +889,7 @@ async def create_websocket_token(username: str | None) -> str:
                 token=token,
                 token_type="websocket",
                 username=username or "",
+                api_key_id=api_key_id,
                 expires_at=expires_at,
             )
         )
@@ -881,14 +897,14 @@ async def create_websocket_token(username: str | None) -> str:
     return token
 
 
-async def verify_websocket_token(token: str) -> str | None:
-    """Verify a WebSocket connect token.
+async def verify_websocket_token_principal(token: str) -> tuple[str, int | None] | None:
+    """Verify a WebSocket connect token and return who minted it.
 
-    Returns the recorded ``username`` (possibly ``""`` for API-key
-    callers, never ``None`` on success) when the token is valid, or
-    ``None`` when it is missing / expired / unknown. The token is
-    NOT consumed — a single page reload should not need a new round
-    trip to mint a replacement.
+    ``(username, api_key_id)``: the username is ``""`` for API-key callers
+    and with auth off; ``api_key_id`` is set only for API keys. ``None`` when
+    the token is missing / expired / unknown. The token is NOT consumed -- a
+    single page reload should not need a new round trip to mint a
+    replacement.
     """
     now = datetime.now(timezone.utc)
     async with async_session() as db:
@@ -902,15 +918,60 @@ async def verify_websocket_token(token: str) -> str | None:
         row = result.scalar_one_or_none()
         if row is None:
             return None
-        return row.username or ""
+        return row.username or "", row.api_key_id
+
+
+async def verify_websocket_token(token: str) -> str | None:
+    """Verify a WebSocket connect token.
+
+    Returns the recorded ``username`` (possibly ``""`` for API-key
+    callers, never ``None`` on success) when the token is valid, or
+    ``None`` when it is missing / expired / unknown.
+    """
+    principal = await verify_websocket_token_principal(token)
+    return None if principal is None else principal[0]
+
+
+_NO_PRINTERS = PrinterScope(frozenset())
+
+
+async def principal_printer_scope(db: AsyncSession, username: str | None, api_key_id: int | None) -> PrinterScope:
+    """Printer scope of the principal a token was minted for (#1727).
+
+    Fail-closed: a key that is gone, disabled, expired or whose owner was
+    deactivated, a user who is gone or deactivated, and a token naming no
+    principal all get no printers. Callers skip this when auth is off.
+    """
+    if api_key_id is not None:
+        api_key = (await db.execute(select(APIKey).where(APIKey.id == api_key_id))).scalar_one_or_none()
+        if api_key is None or not api_key.enabled:
+            return _NO_PRINTERS
+        if api_key.expires_at is not None:
+            expires = api_key.expires_at
+            if expires.tzinfo is None:
+                expires = expires.replace(tzinfo=timezone.utc)
+            if expires < datetime.now(timezone.utc):
+                return _NO_PRINTERS
+        try:
+            return await api_key_printer_scope(db, api_key)
+        except HTTPException:
+            return _NO_PRINTERS
+    if username:
+        user = await get_user_by_username(db, username)
+        if user is None or not user.is_active:
+            return _NO_PRINTERS
+        return await resolve_user_printer_scope(db, user)
+    return _NO_PRINTERS
 
 
-async def verify_camera_stream_token(token: str) -> bool:
-    """Verify a camera stream token is valid (reusable — does not consume it).
+async def verify_camera_stream_token(token: str) -> PrinterScope | None:
+    """Verify a camera stream token (reusable -- does not consume it).
 
-    Tries the ephemeral 60-minute token first (the common, browser-bound case)
-    and falls through to long-lived tokens (#1108) for HA / kiosk integrations
-    that paste a token once and expect it to keep working for days.
+    Returns the printer scope of whoever minted it (#1727), or None when the
+    token is invalid. Tries the ephemeral 60-minute token first (the common,
+    browser-bound case) and falls through to long-lived tokens (#1108) for HA
+    / kiosk integrations that paste a token once and expect it to keep
+    working for days; those carry their owner's scope.
     """
     now = datetime.now(timezone.utc)
     async with async_session() as db:
@@ -921,19 +982,24 @@ async def verify_camera_stream_token(token: str) -> bool:
                 AuthEphemeralToken.expires_at > now,
             )
         )
-        if result.scalar_one_or_none() is not None:
-            return True
+        row = result.scalar_one_or_none()
+        if row is not None:
+            return await principal_printer_scope(db, row.username, row.api_key_id)
 
         # Long-lived path. Imported lazily so the auth module stays importable
         # at startup before the long_lived_tokens model is registered.
         from backend.app.services.long_lived_tokens import STREAM_SCOPES, verify_token as verify_long_lived
 
         record = await verify_long_lived(db, token, scope=STREAM_SCOPES)
-        return record is not None
+        if record is None:
+            return None
+        return await resolve_user_id_printer_scope(db, record.user_id)
+
 
+async def verify_camwall_token(token: str) -> PrinterScope | None:
+    """Verify a Cam Wall token (#2531). Reusable -- does not consume it.
 
-async def verify_camwall_token(token: str) -> bool:
-    """Verify a Cam Wall token (#2531). Reusable — does not consume it.
+    Returns the token owner's printer scope (#1727), or None when invalid.
 
     Deliberately narrower than :func:`verify_camera_stream_token`: only the
     long-lived ``camwall`` scope passes. The 60-minute ephemeral token belongs
@@ -947,11 +1013,15 @@ async def verify_camwall_token(token: str) -> bool:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
         record = await verify_long_lived(db, token, scope="camwall")
-        return record is not None
+        if record is None:
+            return None
+        return await resolve_user_id_printer_scope(db, record.user_id)
+
 
+async def verify_overlay_token(token: str) -> PrinterScope | None:
+    """Verify a streaming-overlay token (#2613). Reusable -- does not consume it.
 
-async def verify_overlay_token(token: str) -> bool:
-    """Verify a streaming-overlay token (#2613). Reusable — does not consume it.
+    Returns the token owner's printer scope (#1727), or None when invalid.
 
     Like :func:`verify_camwall_token`, only the matching long-lived scope passes:
     the overlay status feed names the file being printed, so it must not be
@@ -964,7 +1034,9 @@ async def verify_overlay_token(token: str) -> bool:
         from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
 
         record = await verify_long_lived(db, token, scope="overlay")
-        return record is not None
+        if record is None:
+            return None
+        return await resolve_user_id_printer_scope(db, record.user_id)
 
 
 # --- Media tokens (#3025) ---
@@ -1295,6 +1367,11 @@ async def _user_from_api_key(db: AsyncSession, api_key: APIKey) -> User | None:
 # rows, and held in a ContextVar so it cannot outlive the task that set it.
 _validated_api_key: ContextVar[tuple[str, APIKey] | None] = ContextVar("_validated_api_key", default=None)
 
+# Same idea for a JWT: the user the permission gate resolved, so the printer
+# scope lookup (#1727) needn't decode, revocation-check and load it again.
+# Keyed by the raw token for the same reason as above.
+_authenticated_user: ContextVar[tuple[str, User] | None] = ContextVar("_authenticated_user", default=None)
+
 
 async def _validate_api_key(db: AsyncSession, api_key_value: str) -> APIKey | None:
     """Validate an API key and return the APIKey object if valid, None otherwise.
@@ -1771,26 +1848,22 @@ async def check_webhook_permission(db: AsyncSession, api_key: APIKey, permission
         )
 
 
-def check_printer_access(api_key: APIKey, printer_id: int) -> None:
-    """Check if API key has access to the specified printer.
-
-    Args:
-        api_key: The API key object
-        printer_id: The printer ID to check access for
+async def api_key_printer_scope(db: AsyncSession, api_key: APIKey) -> PrinterScope:
+    """The printers ``api_key`` may reach: its own allowlist within its owner's scope.
 
-    Raises:
-        HTTPException: If access is denied
+    Raises 403 when the owner was deactivated or deleted, like every other
+    owner check (see ``resolve_apikey_owner``).
     """
-    # None = global key, access to all printers
-    if api_key.printer_ids is None:
-        return
+    scope = api_key_own_scope(api_key)
+    owner = await resolve_apikey_owner(db, api_key)
+    if owner is not None:
+        scope = scope.intersect(await resolve_user_printer_scope(db, owner))
+    return scope
 
-    # Empty list or printer not in allowed list = no access
-    if printer_id not in api_key.printer_ids:
-        raise HTTPException(
-            status_code=status.HTTP_403_FORBIDDEN,
-            detail=f"API key does not have access to printer {printer_id}",
-        )
+
+async def ensure_api_key_printer_access(db: AsyncSession, api_key: APIKey, printer_id: int) -> None:
+    """Raise 404 unless ``api_key`` may reach ``printer_id`` (see ``api_key_printer_scope``)."""
+    (await api_key_printer_scope(db, api_key)).ensure(printer_id)
 
 
 async def validated_api_key_from_request(
@@ -1835,10 +1908,146 @@ async def current_api_key_if_present(
     return await validated_api_key_from_request(credentials, x_api_key)
 
 
-def require_printer_permission_if_auth_enabled(permission: str | Permission):
-    """Require a permission and enforce an API key's per-printer allowlist."""
+async def resolve_request_printer_scope(
+    credentials: HTTPAuthorizationCredentials | None,
+    x_api_key: str | None,
+) -> PrinterScope:
+    """The printer scope of whoever sent this request (#1727).
+
+    Meant to run after the route's permission gate, which has already turned
+    away bad credentials; it reuses what that gate resolved where it can. With
+    auth on and no usable principal it returns an empty scope, never every
+    printer.
+    """
+    async with async_session() as db:
+        if not await is_auth_enabled(db):
+            return ALL_PRINTERS
+        api_key = await validated_api_key_from_request(credentials, x_api_key)
+        if api_key is not None:
+            return await api_key_printer_scope(db, api_key)
+        if credentials is None:
+            return PrinterScope(frozenset())
+        cached = _authenticated_user.get()
+        if cached is not None and cached[0] == credentials.credentials:
+            user = cached[1]
+        else:
+            user = await get_current_user_optional(credentials)
+            if user is None:
+                return PrinterScope(frozenset())
+        return await resolve_user_printer_scope(db, user)
+
+
+async def get_printer_scope_if_auth_enabled(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+) -> PrinterScope:
+    """FastAPI dependency for ``resolve_request_printer_scope``.
+
+    Declare it after the permission dependency so the gate runs first.
+    """
+    return await resolve_request_printer_scope(credentials, x_api_key)
+
+
+RequestPrinterScope = Depends(get_printer_scope_if_auth_enabled)
+
+
+def queue_review_required_for(user: User | None) -> bool:
+    """Whether jobs ``user`` queues must wait for someone to start them (#1620).
+
+    None is auth-off or a legacy ownerless API key, neither of which has a
+    reviewer above it. Whoever may start every job (queue:update_all) is the
+    reviewer, so their own jobs don't wait either.
+    """
+    return (
+        user is not None
+        and not user.has_permission(Permission.QUEUE_START_UNREVIEWED.value)
+        and not user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
+    )
+
+
+def may_start_queue_item(user: User | None, can_modify_all: bool, created_by_id: int | None) -> bool:
+    """Whether the caller may start a waiting queue item (#1620).
+
+    ``can_modify_all`` is what ``require_ownership_permission`` answered for
+    queue:update_all; an API key only gets it when its owner holds that. Anyone
+    else needs to be allowed to print without review, and the item must be
+    theirs or have no owner yet (a virtual-printer upload, claimed by starting
+    it, #1670).
+    """
+    if can_modify_all or user is None:
+        return True
+    if queue_review_required_for(user):
+        return False
+    return created_by_id is None or created_by_id == user.id
+
+
+async def get_queue_review_required(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+) -> bool:
+    """FastAPI dependency: must the caller's new queue items wait for review (#1620)?
+
+    Permission dependencies answer API-key requests with no user, so the key's
+    owner decides here. Declare it after the permission dependency. With auth
+    on and no usable principal it answers True.
+    """
+    async with async_session() as db:
+        if not await is_auth_enabled(db):
+            return False
+        api_key = await validated_api_key_from_request(credentials, x_api_key)
+        if api_key is not None:
+            return queue_review_required_for(await resolve_apikey_owner(db, api_key))
+        if credentials is None:
+            return True
+        cached = _authenticated_user.get()
+        if cached is not None and cached[0] == credentials.credentials:
+            user = cached[1]
+        else:
+            user = await get_current_user_optional(credentials)
+            if user is None:
+                return True
+        return queue_review_required_for(user)
+
+
+QueueReviewRequired = Depends(get_queue_review_required)
+
+
+async def get_media_or_request_printer_scope(
+    token: str | None = None,
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+) -> PrinterScope:
+    """``RequestPrinterScope`` for routes an ``<img>`` may load with ``?token=``.
+
+    Such a request carries a media token and no headers, so the header-based
+    lookup would find nobody and answer with no printers. With headers present
+    they win, exactly as in ``require_media_token_*``.
+    """
+    if token and credentials is None and x_api_key is None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return ALL_PRINTERS
+            username = await verify_media_token(token)
+            if not username:
+                return _NO_PRINTERS
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                return _NO_PRINTERS
+            return await resolve_user_printer_scope(db, user)
+    return await resolve_request_printer_scope(credentials, x_api_key)
+
+
+MediaOrRequestPrinterScope = Depends(get_media_or_request_printer_scope)
+
+
+def require_printer_permission_if_auth_enabled(*permissions: str | Permission):
+    """Require permissions and that the path's ``printer_id`` is in the caller's scope.
+
+    For routes with ``{printer_id}`` in the path. A printer outside the scope
+    gets 404, the same as one that doesn't exist.
+    """
 
-    permission_checker = require_permission_if_auth_enabled(permission)
+    permission_checker = require_permission_if_auth_enabled(*permissions)
 
     async def checker(
         printer_id: int,
@@ -1846,9 +2055,8 @@ def require_printer_permission_if_auth_enabled(permission: str | Permission):
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     ) -> User | None:
         user = await permission_checker(credentials=credentials, x_api_key=x_api_key)
-        api_key = await validated_api_key_from_request(credentials, x_api_key)
-        if api_key is not None:
-            check_printer_access(api_key, printer_id)
+        scope = await resolve_request_printer_scope(credentials, x_api_key)
+        scope.ensure(printer_id)
         return user
 
     return checker
@@ -2041,6 +2249,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
                         status_code=status.HTTP_403_FORBIDDEN,
                         detail=f"Missing required permissions: {', '.join(perm_strings)}",
                     )
+                _authenticated_user.set((token, user))
                 return user
 
             # No credentials provided
@@ -2063,10 +2272,10 @@ def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
     return Depends(require_permission_if_auth_enabled(*permissions))
 
 
-def RequirePrinterPermissionIfAuthEnabled(permission: str | Permission):
-    """Require a permission plus any API-key ``printer_ids`` restriction."""
+def RequirePrinterPermissionIfAuthEnabled(*permissions: str | Permission):
+    """Require permissions plus the caller's printer scope for the path's ``printer_id``."""
 
-    return Depends(require_printer_permission_if_auth_enabled(permission))
+    return Depends(require_printer_permission_if_auth_enabled(*permissions))
 
 
 def probe_permissions_if_auth_enabled(*permissions: str | Permission):
@@ -2172,6 +2381,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
                         status_code=status.HTTP_403_FORBIDDEN,
                         detail=f"Missing required permissions: {', '.join(perm_strings)}",
                     )
+                _authenticated_user.set((token, user))
                 return user
 
             raise HTTPException(
@@ -2202,15 +2412,18 @@ def require_camera_stream_token_if_auth_enabled():
     tell one user's rows from another's (#3025).
     """
 
-    async def checker(token: str | None = None) -> None:
+    async def checker(printer_id: int, token: str | None = None) -> None:
         async with async_session() as db:
             if not await is_auth_enabled(db):
                 return  # Auth disabled, allow access
-        if not token or not await verify_camera_stream_token(token):
+        scope = await verify_camera_stream_token(token) if token else None
+        if scope is None:
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Valid camera stream token required. Obtain one from POST /api/v1/printers/camera/stream-token",
             )
+        # The token's minter may not see this printer (#1727)
+        scope.ensure(printer_id)
 
     return checker
 
@@ -2223,17 +2436,20 @@ def require_camwall_token_if_auth_enabled():
 
     Used by the read-only Cam Wall feed (#2531), which a kiosk browser loads
     with the token in the URL because it has no login session to carry a JWT.
+    Returns the token owner's printer scope, which the feed filters by (#1727).
     """
 
-    async def checker(token: str | None = None) -> None:
+    async def checker(token: str | None = None) -> PrinterScope:
         async with async_session() as db:
             if not await is_auth_enabled(db):
-                return  # Auth disabled, allow access
-        if not token or not await verify_camwall_token(token):
+                return ALL_PRINTERS  # Auth disabled, allow access
+        scope = await verify_camwall_token(token) if token else None
+        if scope is None:
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Valid Cam Wall token required. Create one under Settings > API Keys with the 'Cam Wall' scope.",
             )
+        return scope
 
     return checker
 
@@ -2250,15 +2466,18 @@ def require_overlay_token_if_auth_enabled():
     has no JWT to carry.
     """
 
-    async def checker(token: str | None = None) -> None:
+    async def checker(printer_id: int, token: str | None = None) -> None:
         async with async_session() as db:
             if not await is_auth_enabled(db):
                 return  # Auth disabled, allow access
-        if not token or not await verify_overlay_token(token):
+        scope = await verify_overlay_token(token) if token else None
+        if scope is None:
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
                 detail="Valid overlay token required. Create one under Settings > API Keys with the 'Streaming Overlay' scope.",
             )
+        # The token owner may not see this printer (#1727)
+        scope.ensure(printer_id)
 
     return checker
 
@@ -2266,6 +2485,30 @@ def require_overlay_token_if_auth_enabled():
 RequireOverlayTokenIfAuthEnabled = Depends(require_overlay_token_if_auth_enabled())
 
 
+def require_overlay_token_any_printer_if_auth_enabled():
+    """The overlay-token check for overlay assets that belong to no printer.
+
+    The overlay logo (#3104) is one per installation, so there is no printer
+    for the token owner's scope (#1727) to be checked against; a valid overlay
+    token is all it takes, as before.
+    """
+
+    async def checker(token: str | None = None) -> None:
+        async with async_session() as db:
+            if not await is_auth_enabled(db):
+                return  # Auth disabled, allow access
+        if token is None or await verify_overlay_token(token) is None:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail="Valid overlay token required. Create one under Settings > API Keys with the 'Streaming Overlay' scope.",
+            )
+
+    return checker
+
+
+RequireOverlayTokenAnyPrinterIfAuthEnabled = Depends(require_overlay_token_any_printer_if_auth_enabled())
+
+
 def require_ownership_permission(
     all_permission: str | Permission,
     own_permission: str | Permission,
@@ -2505,10 +2748,10 @@ def require_media_token_ownership(
 def require_media_token_printer_permission(permission: str | Permission):
     """Media-route dependency for per-printer resources (#3025).
 
-    :func:`require_media_token_permission` plus the API key's per-printer
-    allowlist, mirroring :func:`require_printer_permission_if_auth_enabled`.
-    Only the header path can present an API key -- a media token resolves to a
-    real user or to nothing -- so the allowlist check applies there alone.
+    :func:`require_media_token_permission` plus the caller's printer scope for
+    the path's ``printer_id``, mirroring
+    :func:`require_printer_permission_if_auth_enabled`. A media token names a
+    user, so their scope applies; a header caller gets the request's scope.
     """
     media_checker = require_media_token_permission(permission)
 
@@ -2519,9 +2762,12 @@ def require_media_token_printer_permission(permission: str | Permission):
         x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
     ) -> User | None:
         user = await media_checker(token=token, credentials=credentials, x_api_key=x_api_key)
-        api_key = await validated_api_key_from_request(credentials, x_api_key)
-        if api_key is not None:
-            check_printer_access(api_key, printer_id)
+        if token and user is not None:
+            async with async_session() as db:
+                scope = await resolve_user_printer_scope(db, user)
+        else:
+            scope = await resolve_request_printer_scope(credentials, x_api_key)
+        scope.ensure(printer_id)
         return user
 
     return checker

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

@@ -5247,6 +5247,28 @@ async def run_migrations(conn):
             text("UPDATE printers SET camera_light_auto = :off WHERE camera_light_auto IS NULL"), {"off": False}
         )
 
+    # Migration: printer-scoped groups (#1727). Defaults off, so no existing
+    # group narrows anyone's printers on upgrade; the group_printers table
+    # itself comes from create_all(). The backfill covers a table create_all()
+    # already gave the column, where the ALTER is swallowed as a duplicate.
+    await _safe_execute(conn, "ALTER TABLE groups ADD COLUMN restrict_printers BOOLEAN DEFAULT FALSE")
+    async with conn.begin_nested():
+        await conn.execute(
+            text("UPDATE groups SET restrict_printers = :off WHERE restrict_printers IS NULL"), {"off": False}
+        )
+
+    # Migration: camera-stream and websocket tokens record who minted them, so
+    # they carry that caller's printer scope (#1727). Camera tokens minted
+    # before this have no principal at all (username NULL; every new one sets
+    # it, "" for API keys and auth-off), and would now resolve to no printers.
+    # They live 60 minutes; dropping them sends the browser to mint a new one.
+    await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN api_key_id INTEGER")
+    async with conn.begin_nested():
+        await conn.execute(
+            text("DELETE FROM auth_ephemeral_tokens WHERE token_type = :t AND username IS NULL"),
+            {"t": "camera_stream"},
+        )
+
 
 async def _migrate_confirm_prompt_body_template(conn) -> None:
     """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).
@@ -5929,6 +5951,13 @@ async def seed_notification_templates():
         await session.commit()
 
 
+# Groups holding any of these before #1620 could queue, start or run jobs that
+# went ahead on their own, so the upgrade lets them keep doing that.
+_QUEUE_REVIEW_BACKFILL_FROM = frozenset(
+    {"queue:create", "queue:update_own", "queue:update_all", "printers:control", "pipelines:run"}
+)
+
+
 async def seed_default_groups():
     """Seed default groups and migrate existing users to appropriate groups.
 
@@ -6253,6 +6282,26 @@ async def seed_default_groups():
                 group.permissions = perms
         await session.commit()
 
+        # queue:start_unreviewed (#1620): jobs of users without it wait for
+        # someone to start them. Granted once to every group that could queue,
+        # start or run jobs before it existed, so nothing changes on upgrade. Once
+        # only, unlike the backfills above: an admin removing it from a group is
+        # the whole point, and a per-boot backfill would hand it straight back.
+        from backend.app.models.settings import Settings
+
+        review_flag = "_backfill_1620_queue_start_unreviewed_done"
+        if (await session.execute(select(Settings).where(Settings.key == review_flag))).scalar_one_or_none() is None:
+            result = await session.execute(select(Group))
+            for group in result.scalars().all():
+                perms = list(group.permissions or [])
+                if "queue:start_unreviewed" in perms:
+                    continue
+                if _QUEUE_REVIEW_BACKFILL_FROM.intersection(perms):
+                    group.permissions = [*perms, "queue:start_unreviewed"]
+                    logger.info("Added queue:start_unreviewed to group '%s' (#1620)", group.name)
+            session.add(Settings(key=review_flag, value="true"))
+            await session.commit()
+
         # Migrate existing users to groups if they're not already in any group
         if groups_created:
             # Refresh to get newly created groups

+ 6 - 0
backend/app/core/permissions.py

@@ -50,6 +50,10 @@ class Permission(StrEnum):
     QUEUE_DELETE_OWN = "queue:delete_own"
     QUEUE_DELETE_ALL = "queue:delete_all"
     QUEUE_REORDER = "queue:reorder"
+    # Without it, every job the user queues waits until someone with
+    # queue:update_all starts it (#1620). Only the waiting is enforced here;
+    # queue:create still decides whether they may queue at all.
+    QUEUE_START_UNREVIEWED = "queue:start_unreviewed"
 
     # Library
     LIBRARY_READ = "library:read"
@@ -237,6 +241,7 @@ PERMISSION_CATEGORIES = {
         Permission.QUEUE_DELETE_OWN,
         Permission.QUEUE_DELETE_ALL,
         Permission.QUEUE_REORDER,
+        Permission.QUEUE_START_UNREVIEWED,
     ],
     "Library": [
         Permission.LIBRARY_READ,  # legacy — kept for back-compat with custom roles
@@ -414,6 +419,7 @@ DEFAULT_GROUPS = {
             Permission.QUEUE_UPDATE_OWN.value,
             Permission.QUEUE_DELETE_OWN.value,
             Permission.QUEUE_REORDER.value,
+            Permission.QUEUE_START_UNREVIEWED.value,
             # Library - own items only
             Permission.LIBRARY_READ_OWN.value,
             Permission.LIBRARY_UPLOAD.value,

+ 199 - 0
backend/app/core/printer_scope.py

@@ -0,0 +1,199 @@
+"""Which printers a caller may see and control (#1727).
+
+Permissions answer *what* a caller may do; a printer scope answers *on which
+printers*. The two are checked separately: a route first passes its
+permission gate, then refuses any printer outside the caller's scope.
+
+How a scope is derived:
+
+* Auth disabled, or an admin user: every printer.
+* A user: the union of the printers of each of their groups that has
+  ``restrict_printers`` set, a group's printers being the ones picked for it
+  plus every printer whose location it was given. A user in no such group sees every printer, so
+  installs that never configure this behave exactly as before. A group
+  without the flag doesn't contribute, so permission groups (Operators,
+  Viewers) combine with team groups without widening them.
+* An API key: its own ``printer_ids`` (None = all), narrowed to its owner's
+  scope, so a key can never reach a printer its owner can't.
+
+A printer outside the scope is reported as missing (404), never as forbidden,
+so its id isn't confirmed to a caller who can't see it.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Iterable
+from dataclasses import dataclass
+
+from fastapi import HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.group import Group, group_locations, group_printers
+
+
+@dataclass(frozen=True)
+class PrinterScope:
+    """The printers a caller may use. ``printer_ids=None`` means all of them."""
+
+    printer_ids: frozenset[int] | None = None
+
+    @property
+    def is_unrestricted(self) -> bool:
+        return self.printer_ids is None
+
+    def allows(self, printer_id: int | None) -> bool:
+        """Whether ``printer_id`` is in scope. ``None`` (no printer) always is."""
+        if printer_id is None or self.printer_ids is None:
+            return True
+        return printer_id in self.printer_ids
+
+    def ensure(self, printer_id: int | None) -> None:
+        """Raise the same 404 a missing printer gets when ``printer_id`` is out of scope."""
+        if not self.allows(printer_id):
+            raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Printer not found")
+
+    def intersect(self, other: PrinterScope) -> PrinterScope:
+        if self.printer_ids is None:
+            return other
+        if other.printer_ids is None:
+            return self
+        return PrinterScope(self.printer_ids & other.printer_ids)
+
+    def filter_ids(self, printer_ids: Iterable[int]) -> list[int]:
+        """``printer_ids`` minus the ones out of scope, order kept."""
+        return [pid for pid in printer_ids if self.allows(pid)]
+
+    def where(self, column):
+        """A WHERE clause limiting ``column`` (a printer id column) to the scope.
+
+        Returns None when unrestricted so callers can skip the filter. Rows
+        whose printer is NULL stay visible: they aren't bound to any printer
+        (orphaned archives, queue items waiting for a model match).
+        """
+        if self.printer_ids is None:
+            return None
+        return column.is_(None) | column.in_(self.printer_ids)
+
+    def where_strict(self, column):
+        """Like ``where`` but also drops rows with no printer."""
+        if self.printer_ids is None:
+            return None
+        return column.in_(self.printer_ids)
+
+
+ALL_PRINTERS = PrinterScope()
+
+
+def ensure_model_target_allowed(user, scope: PrinterScope) -> None:
+    """Refuse an "any printer of a model" job from a limited caller with no user.
+
+    The scheduler keeps such a job within its *creator's* scope, but a job
+    queued through an API key records no creator, so a key limited to certain
+    printers could otherwise reach the rest of the fleet through it. Users are
+    unaffected: their jobs carry their id.
+    """
+    if user is None and not scope.is_unrestricted:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="A caller limited to certain printers must queue to a specific printer, not to any printer of a model",
+        )
+
+
+async def group_printer_ids(db: AsyncSession, group_id: int) -> list[int]:
+    result = await db.execute(
+        select(group_printers.c.printer_id)
+        .where(group_printers.c.group_id == group_id)
+        .order_by(group_printers.c.printer_id)
+    )
+    return list(result.scalars().all())
+
+
+async def group_location_names(db: AsyncSession, group_id: int) -> list[str]:
+    result = await db.execute(
+        select(group_locations.c.location)
+        .where(group_locations.c.group_id == group_id)
+        .order_by(group_locations.c.location)
+    )
+    return list(result.scalars().all())
+
+
+async def groups_printer_ids(db: AsyncSession, group_ids: Iterable[int]) -> frozenset[int]:
+    """Every printer the given groups reach, picked or through a location."""
+    from backend.app.models.printer import Printer
+
+    ids = list(group_ids)
+    if not ids:
+        return frozenset()
+    picked = select(group_printers.c.printer_id).where(group_printers.c.group_id.in_(ids))
+    located = (
+        select(Printer.id)
+        .join(group_locations, group_locations.c.location == Printer.location)
+        .where(group_locations.c.group_id.in_(ids))
+    )
+    result = await db.execute(picked.union(located))
+    return frozenset(result.scalars().all())
+
+
+async def resolve_user_printer_scope(db: AsyncSession, user) -> PrinterScope:
+    """Scope of a loaded ``User`` (``groups`` must already be loaded)."""
+    if user.is_admin:
+        return ALL_PRINTERS
+    restricted = [g.id for g in user.groups if g.restrict_printers]
+    if not restricted:
+        return ALL_PRINTERS
+    return PrinterScope(await groups_printer_ids(db, restricted))
+
+
+async def location_grantees(db: AsyncSession, locations: Iterable[str | None]) -> list[str]:
+    """Names of the restricted groups granted any of ``locations``.
+
+    A printer moving between these locations changes who can reach it.
+    """
+    names = [loc for loc in locations if loc]
+    if not names:
+        return []
+    result = await db.execute(
+        select(Group.name)
+        .join(group_locations, group_locations.c.group_id == Group.id)
+        .where(group_locations.c.location.in_(names), Group.restrict_printers.is_(True))
+        .distinct()
+        .order_by(Group.name)
+    )
+    return list(result.scalars().all())
+
+
+async def resolve_user_id_printer_scope(db: AsyncSession, user_id: int | None) -> PrinterScope:
+    """Scope of the user with ``user_id``; no user (VP, auth off) means every printer.
+
+    For background work acting on a user's behalf, such as the scheduler
+    choosing a printer for a queued job. A deleted or deactivated user gets an
+    empty scope rather than everything, so their leftover jobs don't spread
+    onto printers they were never allowed to use.
+    """
+    from sqlalchemy.orm import selectinload
+
+    from backend.app.models.user import User
+
+    if user_id is None:
+        return ALL_PRINTERS
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
+    user = result.scalar_one_or_none()
+    if user is None or not user.is_active:
+        return PrinterScope(frozenset())
+    return await resolve_user_printer_scope(db, user)
+
+
+def api_key_own_scope(api_key) -> PrinterScope:
+    """The key's own ``printer_ids`` allowlist, without its owner's narrowing."""
+    if api_key.printer_ids is None:
+        return ALL_PRINTERS
+    return PrinterScope(frozenset(int(pid) for pid in api_key.printer_ids))
+
+
+async def group_restricts_printers(db: AsyncSession, group_ids: Iterable[int]) -> bool:
+    ids = list(group_ids)
+    if not ids:
+        return False
+    result = await db.execute(select(Group.id).where(Group.id.in_(ids), Group.restrict_printers.is_(True)).limit(1))
+    return result.first() is not None

+ 68 - 1
backend/app/core/websocket.py

@@ -1,9 +1,35 @@
 import asyncio
 import json
+import logging
 from typing import Any
 
 from fastapi import WebSocket
 
+logger = logging.getLogger(__name__)
+
+
+def _message_printer_id(message: dict[str, Any]) -> int | None:
+    """The printer a broadcast is about, if any: top-level or inside ``data``."""
+    printer_id = message.get("printer_id")
+    if printer_id is None:
+        data = message.get("data")
+        if isinstance(data, dict):
+            printer_id = data.get("printer_id")
+    return printer_id if isinstance(printer_id, int) else None
+
+
+def _may_receive(connection: WebSocket, printer_id: int | None) -> bool:
+    """Whether ``connection``'s printer scope (#1727) covers ``printer_id``.
+
+    The scope is stamped on the socket at connect (``routes/websocket.py``).
+    A socket without one is refused anything printer-bound, so a connection
+    that slipped past the stamping can't receive every printer's events.
+    """
+    if printer_id is None:
+        return True
+    scope = getattr(connection.state, "bambuddy_printer_scope", None)
+    return scope is not None and scope.allows(printer_id)
+
 
 class ConnectionManager:
     """Manages WebSocket connections and broadcasts."""
@@ -30,9 +56,12 @@ class ConnectionManager:
             return
 
         data = json.dumps(message)
+        printer_id = _message_printer_id(message)
         async with self._lock:
             disconnected = []
             for connection in self.active_connections:
+                if not _may_receive(connection, printer_id):
+                    continue
                 try:
                     await connection.send_text(data)
                 except Exception:
@@ -64,11 +93,12 @@ class ConnectionManager:
             return
 
         data = json.dumps(message)
+        printer_id = _message_printer_id(message)
         async with self._lock:
             disconnected = []
             for connection in self.active_connections:
                 conn_uid = getattr(connection.state, "bambuddy_principal_user_id", None)
-                if conn_uid != user_id:
+                if conn_uid != user_id or not _may_receive(connection, printer_id):
                     continue
                 try:
                     await connection.send_text(data)
@@ -79,6 +109,43 @@ class ConnectionManager:
                 if conn in self.active_connections:
                     self.active_connections.remove(conn)
 
+    async def refresh_printer_scopes(self):
+        """Recompute every connection's printer scope (#1727).
+
+        Called after an admin changes which printers a group may see, or who
+        is in a group, so open dashboards stop (or start) receiving those
+        printers' events without a reconnect.
+        """
+        from backend.app.core.auth import is_auth_enabled, principal_printer_scope
+        from backend.app.core.database import async_session
+        from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
+
+        async with self._lock:
+            connections = list(self.active_connections)
+        if not connections:
+            return
+        try:
+            async with async_session() as db:
+                auth_enabled = await is_auth_enabled(db)
+                for connection in connections:
+                    if not auth_enabled:
+                        connection.state.bambuddy_printer_scope = ALL_PRINTERS
+                        continue
+                    username, api_key_id = getattr(connection.state, "bambuddy_scope_principal", (None, None))
+                    connection.state.bambuddy_printer_scope = await principal_printer_scope(db, username, api_key_id)
+        except Exception:  # SEC-AUTH-EXC: refresh failed → fail closed (empty scope, then disconnect to re-auth)
+            # The old scopes may be wider than what was just granted, so they
+            # can't be kept. Drop every socket to no printers and close it with
+            # the "unauthorised" code: the SPA mints a new token and reconnects,
+            # and its scope is worked out afresh at connect.
+            logger.warning("WebSocket printer scope refresh failed; disconnecting clients", exc_info=True)
+            for connection in connections:
+                connection.state.bambuddy_printer_scope = PrinterScope(frozenset())
+                try:
+                    await connection.close(code=4401)
+                except Exception:  # noqa: BLE001 -- already gone; disconnect() cleans it up
+                    pass
+
     async def send_printer_status(self, printer_id: int, status: dict):
         """Send printer status update to all clients."""
         await self.broadcast(

+ 3 - 1
backend/app/models/__init__.py

@@ -8,7 +8,7 @@ from backend.app.models.color_catalog import ColorCatalogEntry
 from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
-from backend.app.models.group import Group, user_groups
+from backend.app.models.group import Group, group_locations, group_printers, user_groups
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
@@ -78,6 +78,8 @@ __all__ = [
     "User",
     "Group",
     "user_groups",
+    "group_locations",
+    "group_printers",
     "GitHubBackupConfig",
     "GitHubBackupLog",
     "LocalPreset",

+ 4 - 0
backend/app/models/auth_ephemeral.py

@@ -77,6 +77,10 @@ class AuthEphemeralToken(Base):
     # oidc_state: which provider initiated the flow
     provider_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
+    # camera_stream + websocket: the API key that minted the token, when one
+    # did, so the token carries that key's printer scope (#1727)
+    api_key_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
     # oidc_state: replay-protection nonce embedded in the ID token
     nonce: Mapped[str | None] = mapped_column(String(128), nullable=True)
 

+ 28 - 0
backend/app/models/group.py

@@ -23,6 +23,29 @@ user_groups = Table(
     Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
 )
 
+# Printers a group with ``restrict_printers`` set is allowed to see and control
+# (#1727). Rows are only meaningful while the flag is on; turning it off keeps
+# them so the selection survives toggling. SQLite doesn't enforce the FK
+# cascades, so printer and group deletes remove their rows explicitly.
+group_printers = Table(
+    "group_printers",
+    Base.metadata,
+    Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
+    Column("printer_id", Integer, ForeignKey("printers.id", ondelete="CASCADE"), primary_key=True),
+)
+
+# Locations a restricted group may use (#1727): every printer whose
+# ``location`` matches, now or later, so a printer added to "Lab A" reaches
+# the Lab A team without anyone ticking it. Matched exactly against
+# ``printers.location``; a name no printer carries any more simply grants
+# nothing. Like ``group_printers``, kept while the flag is off.
+group_locations = Table(
+    "group_locations",
+    Base.metadata,
+    Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
+    Column("location", String(100), primary_key=True),
+)
+
 
 class Group(Base):
     """Group model for organizing users and assigning permissions.
@@ -30,6 +53,10 @@ class Group(Base):
     Groups contain a list of permissions that are granted to all members.
     Users can belong to multiple groups, and their permissions are additive.
     System groups (Administrators, Operators, Viewers) cannot be deleted.
+
+    Permissions say *what* a member may do; ``restrict_printers`` says *where*.
+    A group without the flag doesn't narrow printer access at all, so it can be
+    combined with a team group that does. See ``core/printer_scope.py``.
     """
 
     __tablename__ = "groups"
@@ -39,6 +66,7 @@ class Group(Base):
     description: Mapped[str | None] = mapped_column(String(500), nullable=True)
     permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
     is_system: Mapped[bool] = mapped_column(Boolean, default=False)
+    restrict_printers: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
 

+ 11 - 0
backend/app/schemas/group.py

@@ -21,6 +21,11 @@ class GroupCreate(BaseModel):
     name: str
     description: str | None = None
     permissions: list[str] = []
+    # Printer scope (#1727): when set, members only see these printers plus
+    # every printer in these locations
+    restrict_printers: bool = False
+    printer_ids: list[int] = []
+    locations: list[str] = []
 
 
 class GroupUpdate(BaseModel):
@@ -29,6 +34,9 @@ class GroupUpdate(BaseModel):
     name: str | None = None
     description: str | None = None
     permissions: list[str] | None = None
+    restrict_printers: bool | None = None
+    printer_ids: list[int] | None = None
+    locations: list[str] | None = None
 
 
 class GroupResponse(BaseModel):
@@ -39,6 +47,9 @@ class GroupResponse(BaseModel):
     description: str | None
     permissions: list[str]
     is_system: bool
+    restrict_printers: bool = False
+    printer_ids: list[int] = []
+    locations: list[str] = []
     user_count: int = 0
     created_at: datetime
     updated_at: datetime

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

@@ -14,6 +14,7 @@ from sqlalchemy import and_, or_, select, text
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.config import settings
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
@@ -1599,6 +1600,7 @@ class ArchiveService:
         limit: int = 50,
         offset: int = 0,
         visible_to_user_id: int | None = None,
+        printer_scope: PrinterScope | None = None,
     ) -> list[PrintArchive]:
         """List archives with optional filtering.
 
@@ -1635,6 +1637,10 @@ class ArchiveService:
         if visible_to_user_id is not None:
             query = query.where(PrintArchive.created_by_id == visible_to_user_id)
 
+        # Only archives from printers the caller may see (#1727)
+        if printer_scope is not None and (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+            query = query.where(clause)
+
         query = query.limit(limit).offset(offset)
         result = await self.db.execute(query)
         return list(result.scalars().all())

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

@@ -7,6 +7,7 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 
 
@@ -81,6 +82,7 @@ class ExportService:
         date_to: datetime | None = None,
         search: str | None = None,
         visible_to_user_id: int | None = None,
+        printer_scope: PrinterScope | None = None,
     ) -> tuple[bytes, str, str]:
         """Export archives to CSV or Excel format.
 
@@ -123,6 +125,8 @@ class ExportService:
             query = query.where(PrintArchive.created_at <= date_to)
         if visible_to_user_id is not None:
             query = query.where(PrintArchive.created_by_id == visible_to_user_id)
+        if printer_scope is not None and (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+            query = query.where(clause)
         if search:
             like_pattern = f"%{search}%"
             query = query.where(
@@ -170,6 +174,7 @@ class ExportService:
         printer_id: int | None = None,
         project_id: int | None = None,
         created_by_id: int | None = None,
+        printer_scope: PrinterScope | None = None,
     ) -> tuple[bytes, str, str]:
         """Export statistics summary to CSV or Excel format.
 
@@ -192,6 +197,7 @@ class ExportService:
             printer_id=printer_id,
             project_id=project_id,
             created_by_id=created_by_id,
+            printer_scope=printer_scope,
         )
 
         # Build stats rows

+ 5 - 0
backend/app/services/failure_analysis.py

@@ -4,6 +4,7 @@ from datetime import date, datetime, time, timedelta, timezone
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.printer import Printer
 
@@ -29,6 +30,7 @@ class FailureAnalysisService:
         printer_id: int | None = None,
         project_id: int | None = None,
         created_by_id: int | None = None,
+        printer_scope: PrinterScope | None = None,
     ) -> dict:
         """Analyze failure patterns across logged print events."""
         # Build base query — separate date vs non-date filters for trend reuse
@@ -76,6 +78,9 @@ class FailureAnalysisService:
                 non_date_filter.append(PrintLogEntry.created_by_id.is_(None))
             else:
                 non_date_filter.append(PrintLogEntry.created_by_id == created_by_id)
+        # Only prints on printers the caller may see (#1727)
+        if printer_scope is not None and (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
+            non_date_filter.append(clause)
         base_filter.extend(non_date_filter)
 
         # Total counts

+ 4 - 0
backend/app/services/oidc_group_sync.py

@@ -142,6 +142,10 @@ async def sync_oidc_user_groups(
 
         user.groups = new_groups
         await db.commit()
+        # The user's open dashboards may now see other printers (#1727)
+        from backend.app.core.websocket import ws_manager
+
+        await ws_manager.refresh_printer_scopes()
         logger.info(
             "OIDC group sync: user %s groups -> %s",
             user.username,

+ 41 - 2
backend/app/services/print_scheduler.py

@@ -19,6 +19,7 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.core.config import settings
 from backend.app.core.database import async_session, run_with_retry
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope, resolve_user_id_printer_scope
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
@@ -1824,6 +1825,21 @@ class PrintScheduler:
                 if candidate.cleanup_library_after_dispatch:
                     consumed_libs.add(lib_id)
 
+            # Printer scope of each job's creator (#1727), so an "any <model>"
+            # job only lands on a printer its creator may use. Loaded lazily,
+            # once per creator per pass.
+            from backend.app.core.auth import is_auth_enabled
+
+            scope_auth_enabled = await is_auth_enabled(db)
+            creator_scopes: dict[int, PrinterScope] = {}
+
+            async def _creator_scope(user_id: int | None) -> PrinterScope:
+                if not scope_auth_enabled or user_id is None:
+                    return ALL_PRINTERS
+                if user_id not in creator_scopes:
+                    creator_scopes[user_id] = await resolve_user_id_printer_scope(db, user_id)
+                return creator_scopes[user_id]
+
             for item in items:
                 # Check scheduled time first (scheduled_time is stored in UTC from ISO string)
                 if item.scheduled_time:
@@ -1852,6 +1868,20 @@ class PrintScheduler:
                     continue
 
                 if item.printer_id:
+                    # Its creator may no longer use this printer (#1727): an
+                    # admin took it away from their group after the job was
+                    # queued, say to keep it free for a training session. Held,
+                    # not failed, so it starts if access comes back or the user
+                    # moves it to a printer they still have.
+                    if not (await _creator_scope(item.created_by_id)).allows(item.printer_id):
+                        await hold_item(
+                            item,
+                            "Its owner no longer has access to this printer — move it to another printer",
+                            notify=False,
+                        )
+                        skip_reasons["printer_out_of_scope"] = skip_reasons.get("printer_out_of_scope", 0) + 1
+                        continue
+
                     # Held by a sensor interlock (#1148). Checked before the
                     # busy_printers test that would otherwise swallow it
                     # silently — "waiting for a printer" and "waiting for you
@@ -2095,6 +2125,7 @@ class PrintScheduler:
                     # user's priority order so the pick is reproducible when more
                     # than one printer is free in the same pass.
                     candidates = _candidates_for(item)
+                    item_scope = await _creator_scope(item.created_by_id)
                     printer_id = None
                     chosen: _ModelCandidate | None = None
                     per_model_reasons: list[tuple[str | None, str]] = []
@@ -2147,6 +2178,7 @@ class PrintScheduler:
                             filament_overrides=filament_overrides,
                             require_plate_clear=require_plate_clear,
                             wakeable_ids=wakeable_printer_ids,
+                            printer_scope=item_scope,
                         )
                         if match_id:
                             printer_id = match_id
@@ -2166,6 +2198,7 @@ class PrintScheduler:
                             busy_printers | interlocked.keys(),
                             wakeable_printer_ids,
                             require_plate_clear,
+                            printer_scope=item_scope,
                         )
                         # An attempt spends the pass's one wake whether or not
                         # it worked: it has already blocked the queue loop for
@@ -2788,12 +2821,14 @@ class PrintScheduler:
         db: AsyncSession,
         model: str,
         target_location: str | None = None,
+        printer_scope: PrinterScope = ALL_PRINTERS,
     ) -> list[Printer]:
         """Active printers of *model*, optionally narrowed to one location.
 
         Shared by the matcher and by the smart-plug wake step (#2786) so both
         answer "which printers can this job run on" from one query — a job can
         only be woken onto a printer the matcher would also have considered.
+        ``printer_scope`` is the job creator's (#1727).
         """
         normalized_model = normalize_printer_model(model) or model
         query = (
@@ -2803,6 +2838,8 @@ class PrintScheduler:
         )
         if target_location:
             query = query.where(Printer.location == target_location)
+        if (clause := printer_scope.where_strict(Printer.id)) is not None:
+            query = query.where(clause)
         result = await db.execute(query)
         return list(result.scalars().all())
 
@@ -2841,6 +2878,7 @@ class PrintScheduler:
         exclude_ids: set[int],
         wakeable_ids: set[int],
         require_plate_clear: bool,
+        printer_scope: PrinterScope = ALL_PRINTERS,
     ) -> tuple[int | None, int | None]:
         """Power on one offline printer a model-based item could run on (#2786).
 
@@ -2876,7 +2914,7 @@ class PrintScheduler:
             if not candidate.target_model:
                 continue
             required_types, filament_overrides = _filament_constraints(candidate)
-            printers = await self._printers_for_model(db, candidate.target_model, target_location)
+            printers = await self._printers_for_model(db, candidate.target_model, target_location, printer_scope)
             for printer in sorted(printers, key=lambda p: p.id):
                 if printer.id in exclude_ids or printer.id not in wakeable_ids:
                     continue
@@ -2952,6 +2990,7 @@ class PrintScheduler:
         filament_overrides: list[dict] | None = None,
         require_plate_clear: bool = True,
         wakeable_ids: set[int] | None = None,
+        printer_scope: PrinterScope = ALL_PRINTERS,
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
 
@@ -2976,7 +3015,7 @@ class PrintScheduler:
             - (None, reason) if no printer is available, with explanation
         """
         normalized_model = normalize_printer_model(model) or model
-        printers = await self._printers_for_model(db, model, target_location)
+        printers = await self._printers_for_model(db, model, target_location, printer_scope)
 
         location_suffix = f" in {target_location}" if target_location else ""
         if not printers:

+ 3 - 1
backend/tests/integration/test_archives_api.py

@@ -648,7 +648,9 @@ class TestArchivesAPI:
                 headers={"X-API-Key": full_key},
             )
 
-        assert response.status_code == 403
+        # An archive from a printer outside the key's scope is as missing as
+        # the printer (#1727), so nothing is listed over FTP.
+        assert response.status_code == 404
         listing.assert_not_awaited()
 
     @pytest.mark.asyncio

+ 5 - 3
backend/tests/integration/test_camwall_api.py

@@ -11,6 +11,8 @@ from __future__ import annotations
 import pytest
 from httpx import AsyncClient
 
+from backend.app.core.printer_scope import ALL_PRINTERS
+
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 
 
@@ -169,7 +171,7 @@ class TestCamWallTokenReachesTheVideo:
         jwt = await _setup_admin(async_client, suffix="_video")
         camwall_token = await _mint(async_client, jwt, scope="camwall")
 
-        assert await verify_camera_stream_token(camwall_token) is True
+        assert await verify_camera_stream_token(camwall_token) == ALL_PRINTERS  # admin-owned: every printer (#1727)
 
     async def test_camera_stream_token_still_passes_its_own_gate(self, async_client: AsyncClient):
         """Regression guard on #1108: widening the accepted scopes must not have
@@ -180,7 +182,7 @@ class TestCamWallTokenReachesTheVideo:
         jwt = await _setup_admin(async_client, suffix="_video_legacy")
         stream_token = await _mint(async_client, jwt, scope="camera_stream")
 
-        assert await verify_camera_stream_token(stream_token) is True
+        assert await verify_camera_stream_token(stream_token) == ALL_PRINTERS  # admin-owned: every printer (#1727)
 
     async def test_camwall_gate_rejects_a_camera_stream_token(self, async_client: AsyncClient):
         from backend.app.core.auth import verify_camwall_token
@@ -188,7 +190,7 @@ class TestCamWallTokenReachesTheVideo:
         jwt = await _setup_admin(async_client, suffix="_gate_narrow")
         stream_token = await _mint(async_client, jwt, scope="camera_stream")
 
-        assert await verify_camwall_token(stream_token) is False
+        assert await verify_camwall_token(stream_token) is None
 
 
 class TestScopeValidation:

+ 6 - 4
backend/tests/integration/test_long_lived_tokens_api.py

@@ -10,6 +10,8 @@ from __future__ import annotations
 import pytest
 from httpx import AsyncClient
 
+from backend.app.core.printer_scope import ALL_PRINTERS
+
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 
 
@@ -293,7 +295,7 @@ class TestCameraStreamTokenVerification:
         )
         long_lived = created.json()["token"]
 
-        assert await verify_camera_stream_token(long_lived) is True
+        assert await verify_camera_stream_token(long_lived) == ALL_PRINTERS  # admin-owned: every printer (#1727)
 
     async def test_revoked_long_lived_token_fails_camera_stream_check(self, async_client: AsyncClient):
         from backend.app.core.auth import verify_camera_stream_token
@@ -311,14 +313,14 @@ class TestCameraStreamTokenVerification:
             f"/api/v1/auth/tokens/{token_id}",
             headers={"Authorization": f"Bearer {token}"},
         )
-        assert await verify_camera_stream_token(long_lived) is False
+        assert await verify_camera_stream_token(long_lived) is None
 
     async def test_garbage_token_fails_camera_stream_check(self, async_client: AsyncClient):
         from backend.app.core.auth import verify_camera_stream_token
 
         await _setup_admin(async_client, suffix="_verify_garbage")
-        assert await verify_camera_stream_token("bblt_aaaaaaaa_garbage") is False
-        assert await verify_camera_stream_token("not-a-real-token") is False
+        assert await verify_camera_stream_token("bblt_aaaaaaaa_garbage") is None
+        assert await verify_camera_stream_token("not-a-real-token") is None
 
 
 class TestHashOnlyOverlayTokens:

+ 2 - 1
backend/tests/integration/test_obico_api.py

@@ -8,6 +8,7 @@ hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
 import pytest
 from httpx import AsyncClient
 
+from backend.app.core.printer_scope import ALL_PRINTERS
 from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
 from backend.app.services.obico_smoothing import PrintState
 
@@ -202,7 +203,7 @@ class TestObicoPrinterStatusNoVerdict:
         # redaction under test is independent of them.
         loaded = {"enabled": True, "enabled_printers": None}
         with patch.object(obico_detection_service, "_load_settings", new=AsyncMock(return_value=loaded)):
-            data = await get_printer_status(user=user)
+            data = await get_printer_status(user=user, printer_scope=ALL_PRINTERS)
         entry = data["per_printer"][1]
         assert entry["class"] == "error"
         assert entry["error"] is None

+ 5 - 4
backend/tests/integration/test_overlay_status_api.py

@@ -14,6 +14,7 @@ from __future__ import annotations
 import pytest
 from httpx import AsyncClient
 
+from backend.app.core.printer_scope import ALL_PRINTERS
 from backend.tests.overlay_helpers import mint_token, setup_admin
 
 pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
@@ -232,7 +233,7 @@ class TestOverlayTokenReachesTheVideo:
         jwt = await setup_admin(async_client, suffix="_video")
         overlay_token = await mint_token(async_client, jwt, scope="overlay")
 
-        assert await verify_camera_stream_token(overlay_token) is True
+        assert await verify_camera_stream_token(overlay_token) == ALL_PRINTERS  # admin-owned: every printer (#1727)
 
     async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
         from backend.app.core.auth import verify_overlay_token
@@ -241,8 +242,8 @@ class TestOverlayTokenReachesTheVideo:
         stream_token = await mint_token(async_client, jwt, scope="camera_stream")
         camwall_token = await mint_token(async_client, jwt, scope="camwall", name="wall")
 
-        assert await verify_overlay_token(stream_token) is False
-        assert await verify_overlay_token(camwall_token) is False
+        assert await verify_overlay_token(stream_token) is None
+        assert await verify_overlay_token(camwall_token) is None
 
     async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient):
         """Symmetric guard: the new scope must not widen the Cam Wall either."""
@@ -251,4 +252,4 @@ class TestOverlayTokenReachesTheVideo:
         jwt = await setup_admin(async_client, suffix="_gate_camwall")
         overlay_token = await mint_token(async_client, jwt, scope="overlay")
 
-        assert await verify_camwall_token(overlay_token) is False
+        assert await verify_camwall_token(overlay_token) is None

+ 859 - 0
backend/tests/integration/test_printer_scope_1727.py

@@ -0,0 +1,859 @@
+"""Printer-scoped access (#1727).
+
+A group with ``restrict_printers`` set limits its members to the printers it
+lists. These tests pin the contract end to end:
+
+* a member sees and acts only on the team's printers; any other printer reads
+  as missing (404), never as forbidden, so its id isn't confirmed;
+* groups without the flag narrow nothing, so a user in no restricted group
+  keeps every printer (upgrades are a no-op) and a permission group combined
+  with a team group doesn't widen the team;
+* a restricted group with no printers grants none -- it does not fall back to
+  every printer;
+* API keys, camera-stream, Cam Wall and WebSocket tokens all carry the scope of
+  whoever created them.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+TEAM_PERMISSIONS = [
+    "printers:read",
+    "printers:control",
+    "camera:view",
+    "queue:read_all",
+    "queue:create",
+    "archives:read_all",
+    "archives:reprint_all",
+    "archives:delete_all",
+    "websocket:connect",
+    "api_keys:create",
+]
+
+
+def _auth(jwt: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {jwt}"}
+
+
+async def _admin_token(async_client: AsyncClient) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "scopeadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "scopeadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _group(
+    async_client: AsyncClient,
+    admin_jwt: str,
+    name: str,
+    *,
+    permissions: list[str] | None = None,
+    printer_ids: list[int] | None = None,
+) -> int:
+    body: dict = {"name": name, "permissions": permissions or []}
+    if printer_ids is not None:
+        body.update(restrict_printers=True, printer_ids=printer_ids)
+    response = await async_client.post("/api/v1/groups/", headers=_auth(admin_jwt), json=body)
+    assert response.status_code == 201, response.text
+    return response.json()["id"]
+
+
+async def _user(async_client: AsyncClient, admin_jwt: str, username: str, group_ids: list[int]) -> tuple[str, int]:
+    created = await async_client.post(
+        "/api/v1/users/",
+        headers=_auth(admin_jwt),
+        json={"username": username, "password": "UserPass1!", "group_ids": group_ids},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"], created.json()["id"]
+
+
+async def _team_member(async_client: AsyncClient, admin_jwt: str, username: str, printer_ids: list[int]):
+    """A user in a permission group plus a team group restricted to *printer_ids*."""
+    perms = await _group(async_client, admin_jwt, f"perms_{username}", permissions=TEAM_PERMISSIONS)
+    team = await _group(async_client, admin_jwt, f"team_{username}", printer_ids=printer_ids)
+    return await _user(async_client, admin_jwt, username, [perms, team])
+
+
+async def _listed_ids(async_client: AsyncClient, headers: dict[str, str]) -> set[int]:
+    response = await async_client.get("/api/v1/printers/", headers=headers)
+    assert response.status_code == 200, response.text
+    return {p["id"] for p in response.json()}
+
+
+class TestVisibility:
+    async def test_user_in_no_restricted_group_sees_every_printer(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        perms = await _group(async_client, admin, "plain", permissions=TEAM_PERMISSIONS)
+        jwt, _ = await _user(async_client, admin, "plain_user", [perms])
+
+        assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
+
+    async def test_team_member_sees_only_team_printers(self, async_client, printer_factory):
+        a, _b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        # The permission group (no flag) must not widen the team group
+        assert await _listed_ids(async_client, _auth(jwt)) == {a.id}
+
+    async def test_hidden_printer_reads_as_missing(self, async_client, printer_factory, mock_printer_manager):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+        headers = _auth(jwt)
+
+        assert (await async_client.get(f"/api/v1/printers/{b.id}", headers=headers)).status_code == 404
+        assert (await async_client.get(f"/api/v1/printers/{b.id}/status", headers=headers)).status_code == 404
+        with patch("backend.app.api.routes.printers.printer_manager") as manager:
+            stop = await async_client.post(f"/api/v1/printers/{b.id}/print/stop", headers=headers)
+            assert stop.status_code == 404
+            manager.stop_print.assert_not_called()
+        # The same 404 a printer id that doesn't exist gets
+        missing = await async_client.get("/api/v1/printers/999999", headers=headers)
+        assert missing.status_code == 404
+        assert (await async_client.get(f"/api/v1/printers/{a.id}", headers=headers)).status_code == 200
+
+    async def test_scope_is_the_union_of_restricted_groups(self, async_client, printer_factory):
+        a = await printer_factory(name="A")
+        b = await printer_factory(name="B")
+        await printer_factory(name="C")
+        admin = await _admin_token(async_client)
+        perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
+        team_a = await _group(async_client, admin, "team_a", printer_ids=[a.id])
+        team_b = await _group(async_client, admin, "team_b", printer_ids=[b.id])
+        jwt, _ = await _user(async_client, admin, "both", [perms, team_a, team_b])
+
+        assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
+
+    async def test_restricted_group_without_printers_grants_none(self, async_client, printer_factory):
+        await printer_factory(name="A")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "locked_out", [])
+
+        assert await _listed_ids(async_client, _auth(jwt)) == set()
+
+    async def test_admin_sees_every_printer(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        await _team_member(async_client, admin, "member", [a.id])
+
+        assert await _listed_ids(async_client, _auth(admin)) == {a.id, b.id}
+
+
+class TestGroupApi:
+    async def test_round_trip(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        group_id = await _group(async_client, admin, "team", printer_ids=[a.id])
+
+        detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
+        assert detail["restrict_printers"] is True
+        assert detail["printer_ids"] == [a.id]
+
+        patched = await async_client.patch(
+            f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"printer_ids": [a.id, b.id]}
+        )
+        assert patched.status_code == 200, patched.text
+        assert patched.json()["printer_ids"] == [a.id, b.id]
+
+        listed = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
+        assert next(g for g in listed if g["id"] == group_id)["printer_ids"] == [a.id, b.id]
+
+        # Turning the flag off keeps the selection
+        off = await async_client.patch(
+            f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"restrict_printers": False}
+        )
+        assert off.json()["restrict_printers"] is False
+        assert off.json()["printer_ids"] == [a.id, b.id]
+
+    async def test_unknown_printer_is_rejected(self, async_client, printer_factory):
+        await printer_factory(name="A")
+        admin = await _admin_token(async_client)
+        response = await async_client.post(
+            "/api/v1/groups/",
+            headers=_auth(admin),
+            json={"name": "team", "restrict_printers": True, "printer_ids": [424242]},
+        )
+        assert response.status_code == 400
+        assert "424242" in response.json()["detail"]
+
+    async def test_administrators_cannot_be_restricted(self, async_client):
+        admin = await _admin_token(async_client)
+        groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
+        admins = next(g for g in groups if g["name"] == "Administrators")
+
+        response = await async_client.patch(
+            f"/api/v1/groups/{admins['id']}", headers=_auth(admin), json={"restrict_printers": True}
+        )
+        assert response.status_code == 400
+
+    async def test_deleting_a_printer_drops_it_from_groups(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        group_id = await _group(async_client, admin, "team", printer_ids=[a.id, b.id])
+
+        with patch("backend.app.api.routes.printers.printer_manager"):
+            deleted = await async_client.delete(f"/api/v1/printers/{b.id}", headers=_auth(admin))
+        assert deleted.status_code == 200, deleted.text
+
+        detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
+        assert detail["printer_ids"] == [a.id]
+
+    async def test_deleting_a_group_drops_its_printer_rows(self, async_client, printer_factory, db_session):
+        from sqlalchemy import select
+
+        from backend.app.models.group import group_printers
+
+        a = await printer_factory(name="A")
+        admin = await _admin_token(async_client)
+        group_id = await _group(async_client, admin, "team", printer_ids=[a.id])
+
+        assert (await async_client.delete(f"/api/v1/groups/{group_id}", headers=_auth(admin))).status_code == 204
+
+        rows = await db_session.execute(select(group_printers).where(group_printers.c.group_id == group_id))
+        assert rows.first() is None
+
+
+class TestApiKeys:
+    async def test_key_is_narrowed_to_its_owners_printers(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        # The key itself is unrestricted, but it can't out-rank its owner
+        created = await async_client.post(
+            "/api/v1/api-keys/", headers=_auth(jwt), json={"name": "k", "can_read_status": True}
+        )
+        assert created.status_code == 200, created.text
+        key_headers = {"X-API-Key": created.json()["key"]}
+
+        assert await _listed_ids(async_client, key_headers) == {a.id}
+        assert (await async_client.get(f"/api/v1/printers/{b.id}", headers=key_headers)).status_code == 404
+        webhook = await async_client.get(f"/api/v1/webhook/printer/{b.id}/status", headers=key_headers)
+        assert webhook.status_code == 404
+
+    async def test_key_printer_ids_now_bind_every_printer_route(self, async_client, printer_factory):
+        """``printer_ids`` used to be checked by the file routes and webhooks only."""
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        created = await async_client.post(
+            "/api/v1/api-keys/",
+            headers=_auth(admin),
+            json={"name": "k", "can_read_status": True, "can_control_printer": True, "printer_ids": [a.id]},
+        )
+        key_headers = {"X-API-Key": created.json()["key"]}
+
+        assert await _listed_ids(async_client, key_headers) == {a.id}
+        with patch("backend.app.api.routes.printers.printer_manager") as manager:
+            stop = await async_client.post(f"/api/v1/printers/{b.id}/print/stop", headers=key_headers)
+            assert stop.status_code == 404
+            manager.stop_print.assert_not_called()
+
+
+class TestTokens:
+    async def test_camera_stream_token_carries_its_minters_scope(self, async_client, printer_factory):
+        from backend.app.core.auth import verify_camera_stream_token
+
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        minted = await async_client.post("/api/v1/printers/camera/stream-token", headers=_auth(jwt))
+        token = minted.json()["token"]
+
+        scope = await verify_camera_stream_token(token)
+        assert scope is not None and scope.printer_ids == frozenset({a.id})
+        snapshot = await async_client.get(f"/api/v1/printers/{b.id}/camera/snapshot?token={token}")
+        assert snapshot.status_code == 404
+
+    async def test_camwall_token_lists_only_its_owners_printers(self, async_client, printer_factory):
+        a, _b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        created = await async_client.post(
+            "/api/v1/auth/tokens",
+            headers=_auth(jwt),
+            json={"name": "wall", "expires_in_days": 30, "scope": "camwall"},
+        )
+        assert created.status_code in (200, 201), created.text
+        token = created.json()["token"]
+
+        wall = await async_client.get(f"/api/v1/camwall/printers?token={token}")
+        assert wall.status_code == 200, wall.text
+        assert [p["id"] for p in wall.json()] == [a.id]
+
+    async def test_websocket_token_carries_its_minters_scope(self, async_client, printer_factory):
+        from backend.app.core.auth import principal_printer_scope, verify_websocket_token_principal
+        from backend.app.core.database import async_session
+
+        a, _b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        token = (await async_client.post("/api/v1/auth/ws-token", headers=_auth(jwt))).json()["token"]
+        principal = await verify_websocket_token_principal(token)
+        assert principal == ("member", None)
+        async with async_session() as db:
+            scope = await principal_printer_scope(db, *principal)
+        assert scope.printer_ids == frozenset({a.id})
+
+    async def test_websocket_token_minted_by_a_key_carries_the_keys_scope(self, async_client, printer_factory):
+        from backend.app.core.auth import principal_printer_scope, verify_websocket_token_principal
+        from backend.app.core.database import async_session
+
+        a, _b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        created = await async_client.post(
+            "/api/v1/api-keys/",
+            headers=_auth(admin),
+            json={"name": "k", "can_read_status": True, "printer_ids": [a.id]},
+        )
+        key_headers = {"X-API-Key": created.json()["key"]}
+
+        token = (await async_client.post("/api/v1/auth/ws-token", headers=key_headers)).json()["token"]
+        principal = await verify_websocket_token_principal(token)
+        assert principal == ("", created.json()["id"])
+        async with async_session() as db:
+            scope = await principal_printer_scope(db, *principal)
+        assert scope.printer_ids == frozenset({a.id})
+
+
+class TestQueueAndHistory:
+    async def test_queue_hides_and_refuses_other_printers(self, async_client, printer_factory, archive_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        archive = await archive_factory(a.id)
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        on_b = await async_client.post(
+            "/api/v1/queue/", headers=_auth(admin), json={"archive_id": archive.id, "printer_id": b.id}
+        )
+        assert on_b.status_code == 200, on_b.text
+
+        listed = await async_client.get("/api/v1/queue/", headers=_auth(jwt))
+        assert on_b.json()["id"] not in {item["id"] for item in listed.json()}
+        item = await async_client.get(f"/api/v1/queue/{on_b.json()['id']}", headers=_auth(jwt))
+        assert item.status_code == 404
+
+        refused = await async_client.post(
+            "/api/v1/queue/", headers=_auth(jwt), json={"archive_id": archive.id, "printer_id": b.id}
+        )
+        assert refused.status_code == 404
+
+    async def test_limited_key_cannot_queue_to_any_printer_of_a_model(
+        self, async_client, printer_factory, archive_factory
+    ):
+        """A key's jobs record no creator, so "any X1C" would escape its printers."""
+        a = await printer_factory(name="A", model="X1C")
+        await printer_factory(name="B", model="X1C")
+        archive = await archive_factory(a.id)
+        admin = await _admin_token(async_client)
+        created = await async_client.post(
+            "/api/v1/api-keys/",
+            headers=_auth(admin),
+            json={"name": "k", "can_queue": True, "printer_ids": [a.id]},
+        )
+        key_headers = {"X-API-Key": created.json()["key"]}
+
+        refused = await async_client.post(
+            "/api/v1/queue/", headers=key_headers, json={"archive_id": archive.id, "target_model": "X1C"}
+        )
+        assert refused.status_code == 400
+        pinned = await async_client.post(
+            "/api/v1/queue/", headers=key_headers, json={"archive_id": archive.id, "printer_id": a.id}
+        )
+        assert pinned.status_code == 200, pinned.text
+
+    async def test_library_add_to_queue_keeps_to_the_scope(self, async_client, printer_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        created = await async_client.post(
+            "/api/v1/api-keys/",
+            headers=_auth(admin),
+            json={"name": "k", "can_queue": True, "printer_ids": [a.id]},
+        )
+        key_headers = {"X-API-Key": created.json()["key"]}
+
+        # Both are refused for the whole request, before any file is looked at
+        hidden = await async_client.post(
+            "/api/v1/library/files/add-to-queue", headers=key_headers, json={"file_ids": [1], "printer_id": b.id}
+        )
+        assert hidden.status_code == 404
+        any_model = await async_client.post(
+            "/api/v1/library/files/add-to-queue", headers=key_headers, json={"file_ids": [1]}
+        )
+        assert any_model.status_code == 400
+
+    async def test_limited_user_may_queue_to_any_printer_of_a_model(
+        self, async_client, printer_factory, archive_factory
+    ):
+        """The job carries the user's id, so the scheduler keeps it to their printers."""
+        a = await printer_factory(name="A", model="X1C")
+        await printer_factory(name="B", model="X1C")
+        archive = await archive_factory(a.id)
+        admin = await _admin_token(async_client)
+        jwt, user_id = await _team_member(async_client, admin, "member", [a.id])
+
+        queued = await async_client.post(
+            "/api/v1/queue/", headers=_auth(jwt), json={"archive_id": archive.id, "target_model": "X1C"}
+        )
+        assert queued.status_code == 200, queued.text
+        assert queued.json()["created_by_id"] == user_id
+
+    async def test_archive_list_keeps_to_the_team_printers(self, async_client, printer_factory, archive_factory):
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        on_a = await archive_factory(a.id, print_name="on-a")
+        on_b = await archive_factory(b.id, print_name="on-b")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+
+        listed = await async_client.get("/api/v1/archives/", headers=_auth(jwt))
+        ids = {row["id"] for row in listed.json()}
+        assert on_a.id in ids
+        assert on_b.id not in ids
+
+
+class TestScheduler:
+    async def test_model_matching_stays_within_the_scope(self, db_session, printer_factory):
+        from backend.app.core.printer_scope import PrinterScope
+        from backend.app.services.print_scheduler import PrintScheduler
+
+        a = await printer_factory(name="A", model="X1C")
+        await printer_factory(name="B", model="X1C")
+
+        printers = await PrintScheduler()._printers_for_model(
+            db_session, "X1C", printer_scope=PrinterScope(frozenset({a.id}))
+        )
+        assert [p.id for p in printers] == [a.id]
+
+    async def test_deactivated_creator_reaches_no_printer(self, async_client, db_session, printer_factory):
+        from backend.app.core.printer_scope import resolve_user_id_printer_scope
+
+        await printer_factory(name="A")
+        admin = await _admin_token(async_client)
+        perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
+        _jwt, user_id = await _user(async_client, admin, "gone", [perms])
+        await async_client.patch(f"/api/v1/users/{user_id}", headers=_auth(admin), json={"is_active": False})
+
+        scope = await resolve_user_id_printer_scope(db_session, user_id)
+        assert scope.printer_ids == frozenset()
+
+
+class TestWebSocketRefresh:
+    async def test_group_change_rescopes_open_sockets(self, async_client, printer_factory):
+        from types import SimpleNamespace
+
+        from backend.app.core.printer_scope import ALL_PRINTERS
+        from backend.app.core.websocket import ws_manager
+
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+        groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
+        team_id = next(g["id"] for g in groups if g["name"] == "team_member")
+
+        socket = SimpleNamespace(
+            state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS, bambuddy_scope_principal=("member", None)),
+            send_text=AsyncMock(),
+        )
+        ws_manager.active_connections.append(socket)
+        try:
+            await async_client.patch(f"/api/v1/groups/{team_id}", headers=_auth(admin), json={"printer_ids": [b.id]})
+            assert socket.state.bambuddy_printer_scope.printer_ids == frozenset({b.id})
+
+            await ws_manager.send_printer_status(a.id, {})
+            socket.send_text.assert_not_awaited()
+            await ws_manager.send_printer_status(b.id, {})
+            socket.send_text.assert_awaited_once()
+        finally:
+            ws_manager.active_connections.remove(socket)
+
+
+class TestByIdAndMedia:
+    """Rows reached by id or by an ``<img>`` media token follow the same scope."""
+
+    async def _archive_with_thumbnail(self, archive_factory, printer_id: int, name: str):
+        import os
+        from pathlib import Path
+
+        from backend.app.core.config import settings
+
+        rel = f"test_thumbs_1727_{os.getpid()}/{name}.png"
+        thumb = Path(settings.base_dir) / rel
+        thumb.parent.mkdir(parents=True, exist_ok=True)
+        thumb.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 32)
+        return await archive_factory(printer_id, thumbnail_path=rel)
+
+    async def test_archives_by_id_and_by_media_token(self, async_client, printer_factory, archive_factory):
+        import os
+        import shutil
+        from pathlib import Path
+
+        from backend.app.core.config import settings
+
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        on_a = await self._archive_with_thumbnail(archive_factory, a.id, "on_a")
+        on_b = await self._archive_with_thumbnail(archive_factory, b.id, "on_b")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [a.id])
+        try:
+            assert (await async_client.get(f"/api/v1/archives/{on_a.id}", headers=_auth(jwt))).status_code == 200
+            assert (await async_client.get(f"/api/v1/archives/{on_b.id}", headers=_auth(jwt))).status_code == 404
+            deleted = await async_client.delete(f"/api/v1/archives/{on_b.id}", headers=_auth(jwt))
+            assert deleted.status_code == 404
+
+            # <img> requests carry a media token and no headers
+            member_token = (await async_client.post("/api/v1/auth/media-token", headers=_auth(jwt))).json()["token"]
+            admin_token = (await async_client.post("/api/v1/auth/media-token", headers=_auth(admin))).json()["token"]
+            own = await async_client.get(f"/api/v1/archives/{on_a.id}/thumbnail?token={member_token}")
+            assert own.status_code == 200
+            hidden = await async_client.get(f"/api/v1/archives/{on_b.id}/thumbnail?token={member_token}")
+            assert hidden.status_code == 404
+            # An admin's thumbnails keep loading: no headers must not mean "no printers"
+            admin_view = await async_client.get(f"/api/v1/archives/{on_b.id}/thumbnail?token={admin_token}")
+            assert admin_view.status_code == 200
+        finally:
+            shutil.rmtree(Path(settings.base_dir) / f"test_thumbs_1727_{os.getpid()}", ignore_errors=True)
+
+    async def test_camera_stop_is_scoped(self, async_client, printer_factory):
+        _a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _team_member(async_client, admin, "member", [_a.id])
+
+        response = await async_client.post(f"/api/v1/printers/{b.id}/camera/stop", headers=_auth(jwt))
+        assert response.status_code == 404
+
+    async def test_clearing_the_print_log_keeps_other_printers_entries(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        a, b = await printer_factory(name="A"), await printer_factory(name="B")
+        await archive_factory(a.id)
+        await archive_factory(b.id)
+        admin = await _admin_token(async_client)
+        perms = await _group(async_client, admin, "perms", permissions=TEAM_PERMISSIONS)
+        team = await _group(async_client, admin, "team", printer_ids=[a.id])
+        jwt, _ = await _user(async_client, admin, "member", [perms, team])
+
+        cleared = await async_client.delete("/api/v1/print-log/", headers=_auth(jwt))
+        assert cleared.status_code == 200, cleared.text
+
+        left = (await db_session.execute(select(PrintLogEntry.printer_id))).scalars().all()
+        assert left == [b.id]
+
+
+async def _location_team(
+    async_client: AsyncClient,
+    admin_jwt: str,
+    username: str,
+    locations: list[str],
+    *,
+    printer_ids: list[int] | None = None,
+    permissions: list[str] | None = None,
+):
+    """A member of a team group given *locations* (and optionally single printers)."""
+    perms = await _group(async_client, admin_jwt, f"perms_{username}", permissions=permissions or TEAM_PERMISSIONS)
+    response = await async_client.post(
+        "/api/v1/groups/",
+        headers=_auth(admin_jwt),
+        json={
+            "name": f"team_{username}",
+            "restrict_printers": True,
+            "printer_ids": printer_ids or [],
+            "locations": locations,
+        },
+    )
+    assert response.status_code == 201, response.text
+    team = response.json()["id"]
+    jwt, _ = await _user(async_client, admin_jwt, username, [perms, team])
+    return jwt, team
+
+
+class TestLocations:
+    async def test_location_grants_its_printers_plus_picked_ones(self, async_client, printer_factory):
+        lab_1 = await printer_factory(name="L1", location="Lab A")
+        lab_2 = await printer_factory(name="L2", location="Lab A")
+        picked = await printer_factory(name="P", location="Lab B")
+        await printer_factory(name="Other", location="Lab B")
+        await printer_factory(name="Nowhere")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _location_team(async_client, admin, "lab", ["Lab A"], printer_ids=[picked.id])
+
+        assert await _listed_ids(async_client, _auth(jwt)) == {lab_1.id, lab_2.id, picked.id}
+
+    async def test_printer_added_to_a_location_is_reached_without_ticking_it(self, async_client, printer_factory):
+        await printer_factory(name="L1", location="Lab A")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _location_team(async_client, admin, "lab", ["Lab A"])
+
+        later = await printer_factory(name="L2", location="Lab A")
+        assert later.id in await _listed_ids(async_client, _auth(jwt))
+
+    async def test_location_no_printer_has_grants_nothing(self, async_client, printer_factory):
+        await printer_factory(name="A", location="Lab A")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _location_team(async_client, admin, "lab", ["Basement"])
+
+        assert await _listed_ids(async_client, _auth(jwt)) == set()
+
+    async def test_locations_ignored_while_the_group_is_not_restricted(self, async_client, printer_factory):
+        a = await printer_factory(name="A", location="Lab A")
+        b = await printer_factory(name="B", location="Lab B")
+        admin = await _admin_token(async_client)
+        jwt, team = await _location_team(async_client, admin, "lab", ["Lab A"])
+        off = await async_client.patch(
+            f"/api/v1/groups/{team}", headers=_auth(admin), json={"restrict_printers": False}
+        )
+        assert off.json()["locations"] == ["Lab A"]
+
+        assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
+
+    async def test_round_trip_trims_and_dedupes(self, async_client):
+        admin = await _admin_token(async_client)
+        created = await async_client.post(
+            "/api/v1/groups/",
+            headers=_auth(admin),
+            json={"name": "team", "restrict_printers": True, "locations": [" Lab A ", "Lab A", "", "Lab B"]},
+        )
+        assert created.status_code == 201, created.text
+        group_id = created.json()["id"]
+        assert created.json()["locations"] == ["Lab A", "Lab B"]
+
+        patched = await async_client.patch(
+            f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"locations": ["Lab C"]}
+        )
+        assert patched.json()["locations"] == ["Lab C"]
+        detail = (await async_client.get(f"/api/v1/groups/{group_id}", headers=_auth(admin))).json()
+        assert detail["locations"] == ["Lab C"]
+        listed = (await async_client.get("/api/v1/groups/", headers=_auth(admin))).json()
+        assert next(g for g in listed if g["id"] == group_id)["locations"] == ["Lab C"]
+
+        # Leaving the field out keeps it
+        untouched = await async_client.patch(
+            f"/api/v1/groups/{group_id}", headers=_auth(admin), json={"description": "x"}
+        )
+        assert untouched.json()["locations"] == ["Lab C"]
+
+    async def test_overlong_location_is_rejected(self, async_client):
+        admin = await _admin_token(async_client)
+        response = await async_client.post(
+            "/api/v1/groups/",
+            headers=_auth(admin),
+            json={"name": "team", "restrict_printers": True, "locations": ["x" * 101]},
+        )
+        assert response.status_code == 400
+
+    async def test_deleting_a_group_drops_its_location_rows(self, async_client, db_session):
+        from sqlalchemy import select
+
+        from backend.app.models.group import group_locations
+
+        admin = await _admin_token(async_client)
+        _, team = await _location_team(async_client, admin, "lab", ["Lab A"])
+        assert (await async_client.delete(f"/api/v1/groups/{team}", headers=_auth(admin))).status_code == 204
+
+        rows = await db_session.execute(select(group_locations).where(group_locations.c.group_id == team))
+        assert rows.first() is None
+
+
+class TestMovingPrinters:
+    """A location grant turns a printer's location into an access setting."""
+
+    async def test_non_admin_cannot_move_a_printer_out_of_a_granted_location(self, async_client, printer_factory):
+        a = await printer_factory(name="A", location="Lab A")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _location_team(
+            async_client, admin, "lab", ["Lab A"], permissions=[*TEAM_PERMISSIONS, "printers:update"]
+        )
+
+        response = await async_client.patch(f"/api/v1/printers/{a.id}", headers=_auth(jwt), json={"location": "Lab B"})
+        assert response.status_code == 403
+        assert (await async_client.get(f"/api/v1/printers/{a.id}", headers=_auth(admin))).json()["location"] == "Lab A"
+
+    async def test_non_admin_cannot_move_a_printer_into_a_granted_location(self, async_client, printer_factory):
+        a = await printer_factory(name="A", location="Lab B")
+        admin = await _admin_token(async_client)
+        await _location_team(async_client, admin, "lab", ["Lab A"])
+        perms = await _group(async_client, admin, "editors", permissions=["printers:read", "printers:update"])
+        jwt, _ = await _user(async_client, admin, "editor", [perms])
+
+        response = await async_client.patch(f"/api/v1/printers/{a.id}", headers=_auth(jwt), json={"location": "Lab A"})
+        assert response.status_code == 403
+
+    async def test_non_admin_may_move_between_locations_no_group_was_given(self, async_client, printer_factory):
+        a = await printer_factory(name="A", location="Shelf 1")
+        admin = await _admin_token(async_client)
+        await _location_team(async_client, admin, "lab", ["Lab A"])
+        perms = await _group(async_client, admin, "editors", permissions=["printers:read", "printers:update"])
+        jwt, _ = await _user(async_client, admin, "editor", [perms])
+
+        response = await async_client.patch(
+            f"/api/v1/printers/{a.id}", headers=_auth(jwt), json={"location": " Shelf 2 "}
+        )
+        assert response.status_code == 200, response.text
+        # Trimmed, so it can match a location given to a group later
+        assert response.json()["location"] == "Shelf 2"
+
+    async def test_admin_move_rescopes_members_and_open_sockets(self, async_client, printer_factory):
+        from types import SimpleNamespace
+
+        from backend.app.core.printer_scope import ALL_PRINTERS
+        from backend.app.core.websocket import ws_manager
+
+        a = await printer_factory(name="A", location="Lab A")
+        b = await printer_factory(name="B", location="Lab B")
+        admin = await _admin_token(async_client)
+        jwt, _ = await _location_team(async_client, admin, "lab", ["Lab A"])
+
+        socket = SimpleNamespace(
+            state=SimpleNamespace(bambuddy_printer_scope=ALL_PRINTERS, bambuddy_scope_principal=("lab", None)),
+            send_text=AsyncMock(),
+        )
+        ws_manager.active_connections.append(socket)
+        try:
+            moved = await async_client.patch(
+                f"/api/v1/printers/{b.id}", headers=_auth(admin), json={"location": "Lab A"}
+            )
+            assert moved.status_code == 200, moved.text
+            assert socket.state.bambuddy_printer_scope.printer_ids == frozenset({a.id, b.id})
+        finally:
+            ws_manager.active_connections.remove(socket)
+
+        assert await _listed_ids(async_client, _auth(jwt)) == {a.id, b.id}
+
+
+class TestLocationsPage:
+    """The Printer Locations page (#2962) moves printers too, so it keeps to the same rules."""
+
+    async def _editor(self, async_client, admin):
+        perms = await _group(async_client, admin, "editors", permissions=["printers:read", "printers:update"])
+        jwt, _ = await _user(async_client, admin, "editor", [perms])
+        return jwt
+
+    async def test_list_shows_a_limited_caller_only_their_locations(self, async_client, printer_factory):
+        mine = await printer_factory(name="M", location="Lab A")
+        await printer_factory(name="T", location="Lab A")
+        await printer_factory(name="O", location="Lab B")
+        admin = await _admin_token(async_client)
+        await async_client.post("/api/v1/printer-locations/", headers=_auth(admin), json={"name": "Empty room"})
+        jwt, _ = await _team_member(async_client, admin, "member", [mine.id])
+
+        listed = (await async_client.get("/api/v1/printer-locations/", headers=_auth(jwt))).json()
+        assert [(loc["name"], loc["printer_count"]) for loc in listed] == [("Lab A", 1)]
+        everything = (await async_client.get("/api/v1/printer-locations/", headers=_auth(admin))).json()
+        assert {loc["name"] for loc in everything} == {"Lab A", "Lab B", "Empty room"}
+
+    async def test_rename_carries_the_grant_so_nobody_loses_access(self, async_client, printer_factory, db_session):
+        from sqlalchemy import select
+
+        from backend.app.models.group import group_locations
+
+        a = await printer_factory(name="A", location="Lab A")
+        admin = await _admin_token(async_client)
+        member, team = await _location_team(async_client, admin, "lab", ["Lab A"])
+        editor = await self._editor(async_client, admin)
+
+        response = await async_client.patch(
+            "/api/v1/printer-locations/", headers=_auth(editor), json={"name": "Lab A", "new_name": "Room 101"}
+        )
+        assert response.status_code == 200, response.text
+        assert a.id in await _listed_ids(async_client, _auth(member))
+        grants = (
+            await db_session.execute(select(group_locations.c.location).where(group_locations.c.group_id == team))
+        ).scalars()
+        assert list(grants) == ["Room 101"]
+
+    async def test_rename_onto_a_granted_name_is_for_admins(self, async_client, printer_factory):
+        await printer_factory(name="A", location="Shelf")
+        admin = await _admin_token(async_client)
+        await _location_team(async_client, admin, "lab", ["Lab A"])  # granted, holds no printer yet
+        editor = await self._editor(async_client, admin)
+
+        body = {"name": "Shelf", "new_name": "Lab A"}
+        response = await async_client.patch("/api/v1/printer-locations/", headers=_auth(editor), json=body)
+        assert response.status_code == 403
+        response = await async_client.patch("/api/v1/printer-locations/", headers=_auth(admin), json=body)
+        assert response.status_code == 200, response.text
+
+    async def test_delete_of_a_granted_location_is_for_admins_and_takes_the_grant(
+        self, async_client, printer_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.group import group_locations
+
+        await printer_factory(name="A", location="Lab A")
+        admin = await _admin_token(async_client)
+        member, _ = await _location_team(async_client, admin, "lab", ["Lab A"])
+        editor = await self._editor(async_client, admin)
+
+        body = {"names": ["Lab A"]}
+        assert (
+            await async_client.post("/api/v1/printer-locations/delete", headers=_auth(editor), json=body)
+        ).status_code == 403
+        assert (
+            await async_client.post("/api/v1/printer-locations/delete", headers=_auth(admin), json=body)
+        ).status_code == 200
+        assert (await db_session.execute(select(group_locations))).first() is None
+
+        # A later location of the same name is not handed to the old group.
+        later = await printer_factory(name="B", location="Lab A")
+        assert later.id not in await _listed_ids(async_client, _auth(member))
+
+    async def test_assign_into_or_out_of_a_granted_location_is_for_admins(self, async_client, printer_factory):
+        inside = await printer_factory(name="In", location="Lab A")
+        outside = await printer_factory(name="Out", location="Shelf")
+        admin = await _admin_token(async_client)
+        await _location_team(async_client, admin, "lab", ["Lab A"])
+        editor = await self._editor(async_client, admin)
+
+        for body in (
+            {"printer_ids": [outside.id], "location": "Lab A"},
+            {"printer_ids": [inside.id], "location": None},
+        ):
+            response = await async_client.post("/api/v1/printer-locations/assign", headers=_auth(editor), json=body)
+            assert response.status_code == 403, body
+        ungranted = {"printer_ids": [outside.id], "location": "Shelf 2"}
+        response = await async_client.post("/api/v1/printer-locations/assign", headers=_auth(editor), json=ungranted)
+        assert response.status_code == 200, response.text
+
+    async def test_a_limited_caller_cannot_move_or_rename_what_they_cannot_see(self, async_client, printer_factory):
+        mine = await printer_factory(name="M", location="Lab A")
+        theirs = await printer_factory(name="T", location="Lab A")
+        admin = await _admin_token(async_client)
+        perms = await _group(
+            async_client, admin, "perms", permissions=["printers:read", "printers:update", *TEAM_PERMISSIONS]
+        )
+        team = await _group(async_client, admin, "team", printer_ids=[mine.id])
+        jwt, _ = await _user(async_client, admin, "member", [perms, team])
+
+        moved = await async_client.post(
+            "/api/v1/printer-locations/assign",
+            headers=_auth(jwt),
+            json={"printer_ids": [theirs.id], "location": "Elsewhere"},
+        )
+        assert moved.status_code == 404
+        renamed = await async_client.patch(
+            "/api/v1/printer-locations/", headers=_auth(jwt), json={"name": "Lab A", "new_name": "Lab Z"}
+        )
+        assert renamed.status_code == 403
+        assert (await async_client.get(f"/api/v1/printers/{theirs.id}", headers=_auth(admin))).json()[
+            "location"
+        ] == "Lab A"

+ 2 - 1
backend/tests/integration/test_printers_api.py

@@ -584,7 +584,8 @@ class TestPrintersAPI:
                 headers=headers,
             )
 
-        assert denied.status_code == 403
+        # Out of scope reads as missing, so the id isn't confirmed (#1727)
+        assert denied.status_code == 404
         assert allowed.status_code == 200
         listing.assert_awaited_once()
 

+ 368 - 0
backend/tests/integration/test_queue_review_1620.py

@@ -0,0 +1,368 @@
+"""Jobs that wait for review (#1620).
+
+A user without ``queue:start_unreviewed`` may still queue, but every job they
+queue waits until someone with ``queue:update_all`` starts it. These tests pin
+every way a job can be created or set going:
+
+* POST /queue/, the library's add-to-queue, a batch dispatch, an API key and the
+  webhook all leave such a user's job waiting, whatever the request asked for;
+* the start button, clearing "wait for manual start" in the editor or the bulk
+  editor, and the webhook start all refuse them, also for ownerless
+  virtual-printer jobs a user with the permission may claim by starting;
+* staff can start them, and users with the permission keep today's behaviour;
+* the upgrade grants the permission once, so removing it from a group sticks.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.core import database as _database_module
+from backend.app.core.config import settings as app_settings
+from backend.app.core.database import seed_default_groups
+from backend.app.models.group import Group
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+# What a student needs to queue an archive and look after their own jobs
+STUDENT = [
+    "printers:read",
+    "archives:read_all",
+    "archives:reprint_all",
+    "queue:read_own",
+    "queue:create",
+    "queue:update_own",
+    "queue:delete_own",
+    "api_keys:create",
+]
+STAFF = [*STUDENT, "queue:read_all", "queue:update_all", "queue:delete_all"]
+
+
+def _auth(jwt: str) -> dict[str, str]:
+    return {"Authorization": f"Bearer {jwt}"}
+
+
+async def _admin_token(async_client: AsyncClient) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "reviewadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "reviewadmin", "password": "AdminPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"]
+
+
+async def _user(async_client: AsyncClient, admin_jwt: str, username: str, permissions: list[str]) -> tuple[str, int]:
+    group = await async_client.post(
+        "/api/v1/groups/", headers=_auth(admin_jwt), json={"name": f"g_{username}", "permissions": permissions}
+    )
+    assert group.status_code == 201, group.text
+    created = await async_client.post(
+        "/api/v1/users/",
+        headers=_auth(admin_jwt),
+        json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
+    )
+    assert created.status_code in (200, 201), created.text
+    login = await async_client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
+    assert login.status_code == 200, login.text
+    return login.json()["access_token"], created.json()["id"]
+
+
+async def _queue(async_client: AsyncClient, headers: dict, printer_id: int, archive_id: int, **extra) -> dict:
+    response = await async_client.post(
+        "/api/v1/queue/", headers=headers, json={"printer_id": printer_id, "archive_id": archive_id, **extra}
+    )
+    assert response.status_code == 200, response.text
+    return response.json()
+
+
+async def _manual_start(item_id: int) -> bool:
+    async with _database_module.async_session() as session:
+        item = (await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+        return item.manual_start
+
+
+@pytest.fixture
+async def setup(async_client, printer_factory, archive_factory):
+    printer = await printer_factory(name="Lab")
+    archive = await archive_factory(printer.id)
+    admin = await _admin_token(async_client)
+    student, student_id = await _user(async_client, admin, "student", STUDENT)
+    staff, _ = await _user(async_client, admin, "staff", STAFF)
+    trusted, _ = await _user(async_client, admin, "trusted", [*STUDENT, "queue:start_unreviewed"])
+    return {
+        "printer": printer,
+        "archive": archive,
+        "admin": admin,
+        "student": student,
+        "student_id": student_id,
+        "staff": staff,
+        "trusted": trusted,
+    }
+
+
+class TestQueueing:
+    async def test_a_students_job_waits_whatever_they_asked_for(self, async_client, setup):
+        item = await _queue(
+            async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id, manual_start=False
+        )
+        assert item["manual_start"] is True
+
+    async def test_a_trusted_users_job_starts_on_its_own(self, async_client, setup):
+        item = await _queue(async_client, _auth(setup["trusted"]), setup["printer"].id, setup["archive"].id)
+        assert item["manual_start"] is False
+
+    async def test_staff_jobs_do_not_wait_without_the_permission(self, async_client, setup):
+        """Whoever may start every job is the reviewer; their own jobs don't wait."""
+        item = await _queue(async_client, _auth(setup["staff"]), setup["printer"].id, setup["archive"].id)
+        assert item["manual_start"] is False
+
+    async def test_auth_off_changes_nothing(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        item = await _queue(async_client, {}, printer.id, archive.id)
+        assert item["manual_start"] is False
+
+    async def test_library_add_to_queue_waits(self, async_client, setup, db_session):
+        from backend.app.models.library import LibraryFile
+
+        rel_path = "archive/library/files/review_probe.gcode.3mf"
+        abs_path = Path(app_settings.base_dir) / rel_path
+        abs_path.parent.mkdir(parents=True, exist_ok=True)
+        abs_path.write_bytes(b"probe")
+        try:
+            lib_file = LibraryFile(
+                filename="review_probe.gcode.3mf",
+                file_path=rel_path,
+                file_size=5,
+                file_type="3mf",
+                created_by_id=setup["student_id"],
+            )
+            db_session.add(lib_file)
+            await db_session.commit()
+
+            response = await async_client.post(
+                "/api/v1/library/files/add-to-queue",
+                headers=_auth(setup["student"]),
+                json={"file_ids": [lib_file.id], "printer_id": setup["printer"].id},
+            )
+            assert response.status_code == 200, response.text
+            added = response.json()["added"]
+            assert len(added) == 1
+            assert await _manual_start(added[0]["queue_item_id"]) is True
+        finally:
+            abs_path.unlink(missing_ok=True)
+
+    async def test_dispatching_more_of_an_order_waits_again(self, async_client, setup):
+        """The clones copy the template's flag, which is off once staff started it."""
+        student = _auth(setup["student"])
+        order = await async_client.post(
+            "/api/v1/queue/batches",
+            headers=student,
+            json={
+                "name": "Order",
+                "archive_id": setup["archive"].id,
+                "plates": [{"plate_id": 1, "quantity_target": 3}],
+            },
+        )
+        assert order.status_code == 200, order.text
+        first = await _queue(
+            async_client, student, setup["printer"].id, setup["archive"].id, batch_id=order.json()["id"], plate_id=1
+        )
+        started = await async_client.post(f"/api/v1/queue/{first['id']}/start", headers=_auth(setup["staff"]))
+        assert started.status_code == 200, started.text
+
+        dispatched = await async_client.post(
+            f"/api/v1/queue/batches/{order.json()['id']}/dispatch", headers=student, json={}
+        )
+        assert dispatched.status_code == 200, dispatched.text
+
+        async with _database_module.async_session() as session:
+            clones = (
+                (
+                    await session.execute(
+                        select(PrintQueueItem).where(
+                            PrintQueueItem.batch_id == order.json()["id"], PrintQueueItem.id != first["id"]
+                        )
+                    )
+                )
+                .scalars()
+                .all()
+            )
+        assert len(clones) == 2
+        assert all(clone.manual_start for clone in clones)
+
+
+class TestStarting:
+    async def test_a_student_cannot_start_their_own_waiting_job(self, async_client, setup):
+        item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["student"]))
+        assert response.status_code == 403
+        assert "review" in response.json()["detail"]
+        assert await _manual_start(item["id"]) is True
+
+    async def test_staff_start_it(self, async_client, setup):
+        item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["staff"]))
+        assert response.status_code == 200, response.text
+        assert await _manual_start(item["id"]) is False
+
+    async def test_a_trusted_user_still_starts_their_own_staged_job(self, async_client, setup):
+        trusted = _auth(setup["trusted"])
+        item = await _queue(async_client, trusted, setup["printer"].id, setup["archive"].id, manual_start=True)
+
+        response = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=trusted)
+        assert response.status_code == 200, response.text
+
+    async def test_an_ownerless_job_is_not_claimable_by_a_student(self, async_client, setup):
+        """Virtual-printer uploads arrive without an owner and are claimed by starting them (#1670)."""
+        item = await _queue(async_client, _auth(setup["admin"]), setup["printer"].id, setup["archive"].id)
+        async with _database_module.async_session() as session:
+            row = (await session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item["id"]))).scalar_one()
+            row.created_by_id = None
+            row.manual_start = True
+            await session.commit()
+
+        refused = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["student"]))
+        assert refused.status_code == 403
+        claimed = await async_client.post(f"/api/v1/queue/{item['id']}/start", headers=_auth(setup["trusted"]))
+        assert claimed.status_code == 200, claimed.text
+
+
+class TestEditing:
+    async def test_clearing_wait_in_the_editor_is_refused(self, async_client, setup):
+        student = _auth(setup["student"])
+        item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item['id']}", headers=student, json={"manual_start": False}
+        )
+        assert response.status_code == 403
+        assert await _manual_start(item["id"]) is True
+
+    async def test_other_edits_still_work(self, async_client, setup):
+        student = _auth(setup["student"])
+        item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item['id']}",
+            headers=student,
+            json={"manual_start": True, "require_previous_success": True},
+        )
+        assert response.status_code == 200, response.text
+
+    async def test_staff_may_clear_it_in_the_editor(self, async_client, setup):
+        item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item['id']}", headers=_auth(setup["staff"]), json={"manual_start": False}
+        )
+        assert response.status_code == 200, response.text
+        assert await _manual_start(item["id"]) is False
+
+    async def test_the_bulk_editor_skips_it(self, async_client, setup):
+        student = _auth(setup["student"])
+        item = await _queue(async_client, student, setup["printer"].id, setup["archive"].id)
+
+        response = await async_client.patch(
+            "/api/v1/queue/bulk", headers=student, json={"item_ids": [item["id"]], "manual_start": False}
+        )
+        assert response.status_code == 200, response.text
+        assert response.json()["updated_count"] == 0
+        assert await _manual_start(item["id"]) is True
+
+
+class TestApiKeys:
+    async def _key(self, async_client, jwt: str, **flags) -> dict[str, str]:
+        created = await async_client.post(
+            "/api/v1/api-keys/", headers=_auth(jwt), json={"name": "k", "can_queue": True, **flags}
+        )
+        assert created.status_code in (200, 201), created.text
+        return {"X-API-Key": created.json()["key"]}
+
+    async def test_a_students_key_queues_jobs_that_wait(self, async_client, setup):
+        key = await self._key(async_client, setup["student"])
+        item = await _queue(async_client, key, setup["printer"].id, setup["archive"].id)
+        assert item["manual_start"] is True
+
+    async def test_a_trusted_users_key_does_not(self, async_client, setup):
+        key = await self._key(async_client, setup["trusted"])
+        item = await _queue(async_client, key, setup["printer"].id, setup["archive"].id)
+        assert item["manual_start"] is False
+
+    async def test_webhook_queue_add_waits(self, async_client, setup):
+        key = await self._key(async_client, setup["student"])
+        response = await async_client.post(
+            "/api/v1/webhook/queue/add",
+            headers=key,
+            json={"printer_id": setup["printer"].id, "archive_id": setup["archive"].id},
+        )
+        assert response.status_code == 200, response.text
+        assert await _manual_start(response.json()["id"]) is True
+
+    async def test_webhook_start_refuses_a_key_whose_owner_needs_review(self, async_client, setup):
+        admin = setup["admin"]
+        # Printer control, but their own jobs wait: they can't release anyone's
+        controller, _ = await _user(async_client, admin, "controller", [*STUDENT, "printers:control"])
+        item = await _queue(async_client, _auth(setup["student"]), setup["printer"].id, setup["archive"].id)
+        key = await self._key(async_client, controller, can_control_printer=True)
+
+        refused = await async_client.post(f"/api/v1/webhook/printer/{setup['printer'].id}/start", headers=key)
+        assert refused.status_code == 403
+        assert await _manual_start(item["id"]) is True
+
+        staff_key = await self._key(async_client, admin, can_control_printer=True)
+        started = await async_client.post(f"/api/v1/webhook/printer/{setup['printer'].id}/start", headers=staff_key)
+        assert started.status_code == 200, started.text
+        assert await _manual_start(item["id"]) is False
+
+
+class TestUpgrade:
+    async def test_granted_once_to_groups_that_could_print(self, async_client):
+        async with _database_module.async_session() as session:
+            session.add_all(
+                [
+                    Group(name="queuers", permissions=["queue:create"], is_system=False),
+                    Group(name="controllers", permissions=["printers:control"], is_system=False),
+                    # Could start their own staged jobs, or run pipelines, without queue:create
+                    Group(name="starters", permissions=["queue:read_all", "queue:update_own"], is_system=False),
+                    Group(name="pipeliners", permissions=["pipelines:run"], is_system=False),
+                    Group(name="readers", permissions=["queue:read_own"], is_system=False),
+                ]
+            )
+            flag = (
+                await session.execute(
+                    select(Settings).where(Settings.key == "_backfill_1620_queue_start_unreviewed_done")
+                )
+            ).scalar_one_or_none()
+            # Seeding already ran once for this database; make it an upgrade again
+            if flag is not None:
+                await session.delete(flag)
+            await session.commit()
+
+        await seed_default_groups()
+
+        async with _database_module.async_session() as session:
+            groups = {g.name: g for g in (await session.execute(select(Group))).scalars().all()}
+            assert "queue:start_unreviewed" in groups["queuers"].permissions
+            assert "queue:start_unreviewed" in groups["controllers"].permissions
+            assert "queue:start_unreviewed" in groups["starters"].permissions
+            assert "queue:start_unreviewed" in groups["pipeliners"].permissions
+            assert "queue:start_unreviewed" not in groups["readers"].permissions
+            # An admin takes it away from the students...
+            groups["queuers"].permissions = ["queue:create"]
+            await session.commit()
+
+        # ...and a restart doesn't hand it back
+        await seed_default_groups()
+        async with _database_module.async_session() as session:
+            queuers = (await session.execute(select(Group).where(Group.name == "queuers"))).scalar_one()
+            assert "queue:start_unreviewed" not in queuers.permissions

+ 2 - 1
backend/tests/integration/test_webhook_printer_status.py

@@ -308,7 +308,8 @@ class TestWebhookPrinterStatusFields:
         await db_session.commit()
 
         resp = await _status(async_client, full_key, printer_row.id, PrinterState(connected=True))
-        assert resp.status_code == 403
+        # Out of scope reads as missing, so the id isn't confirmed (#1727)
+        assert resp.status_code == 404
         assert "00M00A000000010" not in resp.text
 
 

+ 7 - 3
backend/tests/unit/test_archive_filtering.py

@@ -10,6 +10,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 
+from backend.app.core.printer_scope import ALL_PRINTERS
+
 # Patch paths for lazy imports inside functions
 _FTP_MODULE = "backend.app.services.bambu_ftp"
 
@@ -847,7 +849,9 @@ class TestDeleteTimelapse:
         with patch("backend.app.api.routes.archives.settings") as mock_settings:
             mock_settings.base_dir = tmp_path
             # auth_result=(None, True) → the auth-disabled / can_modify_all path.
-            result = await delete_timelapse(archive_id=1, db=mock_db, auth_result=(None, True))
+            result = await delete_timelapse(
+                archive_id=1, db=mock_db, auth_result=(None, True), printer_scope=ALL_PRINTERS
+            )
 
         assert result == {"status": "deleted"}
         assert mock_archive.timelapse_path is None
@@ -871,7 +875,7 @@ class TestDeleteTimelapse:
         mock_db.execute = AsyncMock(return_value=mock_result)
 
         with pytest.raises(HTTPException) as exc_info:
-            await delete_timelapse(archive_id=1, db=mock_db, auth_result=(None, True))
+            await delete_timelapse(archive_id=1, db=mock_db, auth_result=(None, True), printer_scope=ALL_PRINTERS)
 
         assert exc_info.value.status_code == 404
 
@@ -888,6 +892,6 @@ class TestDeleteTimelapse:
         mock_db.execute = AsyncMock(return_value=mock_result)
 
         with pytest.raises(HTTPException) as exc_info:
-            await delete_timelapse(archive_id=999, db=mock_db, auth_result=(None, True))
+            await delete_timelapse(archive_id=999, db=mock_db, auth_result=(None, True), printer_scope=ALL_PRINTERS)
 
         assert exc_info.value.status_code == 404

+ 128 - 0
backend/tests/unit/test_printer_scope.py

@@ -0,0 +1,128 @@
+"""PrinterScope semantics and WebSocket fan-out filtering (#1727)."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from fastapi import HTTPException
+
+from backend.app.core.printer_scope import ALL_PRINTERS, PrinterScope
+from backend.app.core.websocket import ConnectionManager
+
+
+class TestPrinterScope:
+    def test_unrestricted_allows_everything(self):
+        assert ALL_PRINTERS.is_unrestricted
+        assert ALL_PRINTERS.allows(1)
+        assert ALL_PRINTERS.where(None) is None
+
+    def test_restricted_allows_only_its_printers(self):
+        scope = PrinterScope(frozenset({1, 2}))
+        assert scope.allows(1)
+        assert not scope.allows(3)
+        assert scope.filter_ids([3, 2, 1]) == [2, 1]
+
+    def test_no_printer_is_always_in_scope(self):
+        # Rows not bound to a printer (orphaned archives, model-based jobs)
+        assert PrinterScope(frozenset()).allows(None)
+
+    def test_ensure_reports_missing_not_forbidden(self):
+        with pytest.raises(HTTPException) as exc:
+            PrinterScope(frozenset({1})).ensure(2)
+        assert exc.value.status_code == 404
+        assert exc.value.detail == "Printer not found"
+
+    def test_intersect(self):
+        team = PrinterScope(frozenset({1, 2}))
+        assert ALL_PRINTERS.intersect(team) == team
+        assert team.intersect(ALL_PRINTERS) == team
+        assert team.intersect(PrinterScope(frozenset({2, 3}))) == PrinterScope(frozenset({2}))
+
+
+def _socket(scope: PrinterScope | None):
+    state = SimpleNamespace()
+    if scope is not None:
+        state.bambuddy_printer_scope = scope
+    return SimpleNamespace(state=state, send_text=AsyncMock())
+
+
+class TestBroadcastFiltering:
+    @pytest.mark.asyncio
+    async def test_printer_events_reach_only_sockets_that_may_see_the_printer(self):
+        mgr = ConnectionManager()
+        team = _socket(PrinterScope(frozenset({1})))
+        everyone = _socket(ALL_PRINTERS)
+        mgr.active_connections = [team, everyone]
+
+        await mgr.send_printer_status(2, {})
+
+        team.send_text.assert_not_awaited()
+        everyone.send_text.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_printer_id_inside_data_is_honoured(self):
+        mgr = ConnectionManager()
+        team = _socket(PrinterScope(frozenset({1})))
+        mgr.active_connections = [team]
+
+        await mgr.send_archive_created({"id": 9, "printer_id": 2})
+        team.send_text.assert_not_awaited()
+        await mgr.send_archive_created({"id": 10, "printer_id": 1})
+        team.send_text.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_messages_about_no_printer_reach_everyone(self):
+        mgr = ConnectionManager()
+        team = _socket(PrinterScope(frozenset()))
+        mgr.active_connections = [team]
+
+        await mgr.broadcast({"type": "inventory_changed"})
+
+        team.send_text.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_socket_without_a_scope_gets_no_printer_events(self):
+        """Fail closed: a socket that missed the connect-time stamp hears nothing printer-bound."""
+        mgr = ConnectionManager()
+        unstamped = _socket(None)
+        mgr.active_connections = [unstamped]
+
+        await mgr.send_printer_status(1, {})
+        unstamped.send_text.assert_not_awaited()
+        await mgr.broadcast({"type": "inventory_changed"})
+        unstamped.send_text.assert_awaited_once()
+
+    @pytest.mark.asyncio
+    async def test_targeted_broadcast_is_filtered_too(self):
+        mgr = ConnectionManager()
+        own = _socket(PrinterScope(frozenset({1})))
+        own.state.bambuddy_principal_user_id = 7
+        mgr.active_connections = [own]
+
+        await mgr.send_queue_item_acked(7, queue_item_id=1, printer_id=2)
+
+        own.send_text.assert_not_awaited()
+
+
+class TestScopeRefresh:
+    @pytest.mark.asyncio
+    async def test_a_failed_refresh_fails_closed(self):
+        """Old scopes may be wider than what was just granted, so they can't be kept."""
+        from unittest.mock import patch
+
+        mgr = ConnectionManager()
+        socket = _socket(ALL_PRINTERS)
+        socket.state.bambuddy_scope_principal = ("member", None)
+        socket.close = AsyncMock()
+        mgr.active_connections = [socket]
+
+        with (
+            patch("backend.app.core.auth.is_auth_enabled", AsyncMock(return_value=True)),
+            patch("backend.app.core.auth.principal_printer_scope", AsyncMock(side_effect=RuntimeError("db down"))),
+        ):
+            await mgr.refresh_printer_scopes()
+
+        assert socket.state.bambuddy_printer_scope == PrinterScope(frozenset())
+        socket.close.assert_awaited_once_with(code=4401)

+ 3 - 2
backend/tests/unit/test_route_auth_coverage.py

@@ -164,7 +164,8 @@ def _has_auth_dep(dependant) -> bool:
 
 
 def _ws_endpoint_does_inline_token_check(route: APIWebSocketRoute) -> bool:
-    """True if the websocket endpoint reads its source uses ``verify_websocket_token``.
+    """True if the websocket endpoint's source calls ``verify_websocket_token``
+    (or its ``_principal`` variant, which also returns the minting API key).
 
     WebSocket routes don't pass auth via the standard Depends machinery
     (the WebSocket handshake doesn't carry headers), so the auth check
@@ -180,7 +181,7 @@ def _ws_endpoint_does_inline_token_check(route: APIWebSocketRoute) -> bool:
         source = inspect.getsource(route.endpoint)
     except (OSError, TypeError):
         return False
-    return bool(re.search(r"\bverify_websocket_token\s*\(", source))
+    return bool(re.search(r"\bverify_websocket_token(?:_principal)?\s*\(", source))
 
 
 @pytest.mark.unit

+ 3 - 1
backend/tests/unit/test_timelapse_scan_2704.py

@@ -21,6 +21,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 
+from backend.app.core.printer_scope import ALL_PRINTERS
+
 logger = logging.getLogger(__name__)
 
 
@@ -411,7 +413,7 @@ class TestManualScanUsesTheBaseline:
             ),
             patch("backend.app.services.bambu_ftp.delete_archived_timelapse", delete or AsyncMock()),
         ):
-            return await archives_mod.scan_timelapse(archive.id, None)
+            return await archives_mod.scan_timelapse(archive.id, None, ALL_PRINTERS)
 
     @pytest.mark.asyncio
     async def test_attaches_the_single_unclaimed_new_file(self):

+ 7 - 1
backend/tests/unit/test_ws_broadcast_to_user.py

@@ -17,14 +17,20 @@ from unittest.mock import AsyncMock
 
 import pytest
 
+from backend.app.core.printer_scope import ALL_PRINTERS
 from backend.app.core.websocket import ConnectionManager
 
 
 def _mock_conn(user_id: int | None):
-    """Build a stand-in WebSocket-shaped object with the principal stamp."""
+    """Build a stand-in WebSocket-shaped object with the principal stamp.
+
+    Every real connection also gets a printer scope at connect (#1727); these
+    tests are about user routing, so it is the unrestricted one.
+    """
     conn = SimpleNamespace()
     conn.state = SimpleNamespace()
     conn.state.bambuddy_principal_user_id = user_id
+    conn.state.bambuddy_printer_scope = ALL_PRINTERS
     conn.send_text = AsyncMock()
     return conn
 

+ 131 - 0
frontend/src/__tests__/components/EditPrinterLocationAccess.test.tsx

@@ -0,0 +1,131 @@
+/**
+ * Groups can be given a location (#1727), so editing a printer's location is
+ * an access change: the dialog names the groups it affects, and clearing the
+ * location has to actually clear it.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { setAuthToken } from '../../api/client';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1 Carbon',
+  ip_address: '192.168.1.100',
+  serial_number: '00M09A350100001',
+  model: 'X1C',
+  location: 'Lab A',
+  is_active: true,
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const teamGroup = (name: string, locations: string[], restrict = true) => ({
+  id: name.length,
+  name,
+  description: null,
+  permissions: [],
+  is_system: false,
+  restrict_printers: restrict,
+  printer_ids: [],
+  locations,
+  user_count: 1,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+});
+
+let patched: Record<string, unknown> | null;
+
+beforeEach(() => {
+  patched = null;
+  setAuthToken('test-token', 'session');
+  server.use(
+    http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+    http.get('*/api/v1/auth/me', () =>
+      HttpResponse.json({ id: 1, username: 'boss', is_admin: true, groups: [], permissions: ['printers:update', 'printers:read'] })
+    ),
+    http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+    http.get('/api/v1/printers/:id/status', () =>
+      HttpResponse.json({
+        connected: true,
+        state: 'IDLE',
+        progress: 0,
+        layer_num: 0,
+        total_layers: 0,
+        temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+        remaining_time: 0,
+        filename: null,
+        wifi_signal: -50,
+        vt_tray: [],
+      })
+    ),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.get('/api/v1/groups/', () =>
+      HttpResponse.json([
+        teamGroup('Lab A team', ['Lab A']),
+        teamGroup('Lab B crew', ['Lab B']),
+        teamGroup('Unlimited', ['Lab A'], false),
+      ])
+    ),
+    http.post('/api/v1/printers/diagnostic', () =>
+      HttpResponse.json({ printer_id: null, ip_address: '192.168.1.100', overall: 'ok', checks: [] })
+    ),
+    http.patch('/api/v1/printers/:id', async ({ request }) => {
+      patched = (await request.json()) as Record<string, unknown>;
+      return HttpResponse.json({ ...mockPrinter, ...patched });
+    })
+  );
+});
+
+afterEach(() => {
+  setAuthToken(null);
+});
+
+async function openEditModal() {
+  render(<PrintersPage />);
+  await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
+  const menuBtn = [...document.querySelectorAll('button')].find((b) => b.querySelector('.lucide-ellipsis-vertical'))!;
+  await userEvent.click(menuBtn);
+  await userEvent.click(await screen.findByRole('button', { name: /^edit$/i }));
+  await screen.findByText('Edit Printer');
+}
+
+describe('EditPrinterModal location access', () => {
+  it('names the limited groups a move affects', async () => {
+    await openEditModal();
+    const input = screen.getByDisplayValue('Lab A');
+    expect(screen.queryByText(/changes who can use it/)).not.toBeInTheDocument();
+
+    await userEvent.clear(input);
+    await userEvent.type(input, 'Lab B');
+
+    expect(
+      await screen.findByText('Moving this printer to another location changes who can use it: Lab A team, Lab B crew.')
+    ).toBeInTheDocument();
+  });
+
+  it('clears the location instead of keeping the old one', async () => {
+    await openEditModal();
+    await userEvent.clear(screen.getByDisplayValue('Lab A'));
+    await userEvent.click(screen.getByRole('button', { name: /save changes/i }));
+
+    await waitFor(() => expect(patched).not.toBeNull());
+    expect(patched).toHaveProperty('location', null);
+  });
+
+  it('offers "Who has access" in the card menu', async () => {
+    render(<PrintersPage />);
+    await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
+    const menuBtn = [...document.querySelectorAll('button')].find((b) => b.querySelector('.lucide-ellipsis-vertical'))!;
+    await userEvent.click(menuBtn);
+    await userEvent.click(await screen.findByRole('button', { name: 'Who has access' }));
+
+    await waitFor(() => expect(window.location.search).toBe('?tab=users&sub=printer-access&view=printers&printer=1'));
+  });
+});

+ 86 - 1
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -6,7 +6,7 @@
  * - 'edit-queue-item': Edit existing queue item (single printer)
  */
 
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
 import type React from 'react';
 import { screen, waitFor, fireEvent, within, render as rtlRender } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
@@ -18,6 +18,7 @@ import { AuthProvider } from '../../contexts/AuthContext';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ToastProvider } from '../../contexts/ToastContext';
 import { http, HttpResponse } from 'msw';
+import { setAuthToken } from '../../api/client';
 import { server } from '../mocks/server';
 import type { PrintQueueItem } from '../../api/client';
 
@@ -415,6 +416,90 @@ describe('PrintModal', () => {
     });
   });
 
+  // Without queue:start_unreviewed every job waits for staff to start it (#1620)
+  describe('when the user needs review', () => {
+    beforeEach(() => {
+      setAuthToken('test-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+        http.get('*/api/v1/auth/me', () =>
+          HttpResponse.json({
+            id: 7,
+            username: 'student',
+            is_admin: false,
+            permissions: ['printers:read', 'queue:create', 'queue:update_own'],
+          }),
+        ),
+      );
+    });
+
+    afterEach(() => {
+      setAuthToken(null);
+    });
+
+    it('says the job waits and still asks for that when saving as ASAP', async () => {
+      let body: Record<string, unknown> | null = null;
+      server.use(
+        http.patch('/api/v1/queue/:id', async ({ request }) => {
+          body = (await request.json()) as Record<string, unknown>;
+          return HttpResponse.json({ id: 1, status: 'pending' });
+        }),
+      );
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="edit-queue-item"
+          archiveId={1}
+          archiveName="Test Print"
+          queueItem={createMockQueueItem({ manual_start: true, created_by_id: 7 })}
+          onClose={mockOnClose}
+        />
+      );
+
+      expect(
+        await screen.findByText('Your print waits for review: it starts once someone who manages the queue starts it.'),
+      ).toBeInTheDocument();
+      expect(screen.queryByText('Require manual start')).not.toBeInTheDocument();
+
+      await user.click(screen.getByText('ASAP'));
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      // Sending false would clear the wait, which the server refuses for them
+      await waitFor(() => expect(body).not.toBeNull());
+      expect(body!.manual_start).toBe(true);
+    });
+  });
+
+  // A student's scheduled job waits too (#1620); staff editing it must not start it
+  it('keeps a scheduled job waiting when it is edited', async () => {
+    let body: Record<string, unknown> | null = null;
+    server.use(
+      http.patch('/api/v1/queue/:id', async ({ request }) => {
+        body = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ id: 1, status: 'pending' });
+      }),
+    );
+    const user = userEvent.setup();
+    render(
+      <PrintModal
+        mode="edit-queue-item"
+        archiveId={1}
+        archiveName="Test Print"
+        queueItem={createMockQueueItem({
+          manual_start: true,
+          // Tomorrow: over six months ahead reads as the "no date" placeholder
+          scheduled_time: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
+        })}
+        onClose={mockOnClose}
+      />
+    );
+
+    await user.click(screen.getByRole('button', { name: /save/i }));
+
+    await waitFor(() => expect(body).not.toBeNull());
+    expect(body!.manual_start).toBe(true);
+  });
+
   describe('edit-queue-item mode', () => {
     it('renders the modal title', () => {
       const item = createMockQueueItem();

+ 212 - 0
frontend/src/__tests__/components/PrinterAccessSettings.test.tsx

@@ -0,0 +1,212 @@
+/**
+ * Printer access page (#1727): groups reach picked printers plus whole
+ * locations, edited by group or by printer and saved together.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { PrinterAccessSettings } from '../../components/PrinterAccessSettings';
+
+const printer = (id: number, name: string, location: string | null, model = 'X1C') => ({
+  id,
+  name,
+  location,
+  model,
+  serial_number: `SN${id}`,
+  ip_address: `10.0.0.${id}`,
+  is_active: true,
+});
+
+const printers = [
+  printer(1, 'Alpha', 'Lab A'),
+  printer(2, 'Bravo', 'Lab A', 'P1S'),
+  printer(3, 'Charlie', 'Lab B'),
+  printer(4, 'Loose', null),
+];
+
+const group = (id: number, name: string, extra: Record<string, unknown> = {}) => ({
+  id,
+  name,
+  description: null,
+  permissions: [],
+  is_system: false,
+  restrict_printers: false,
+  printer_ids: [],
+  locations: [],
+  user_count: 1,
+  created_at: '2026-01-01T00:00:00Z',
+  updated_at: '2026-01-01T00:00:00Z',
+  ...extra,
+});
+
+const groups = [
+  group(1, 'Administrators', { is_system: true }),
+  group(3, 'Viewers', { is_system: true }),
+  group(5, 'Team A', { restrict_printers: true, printer_ids: [3], locations: ['Lab A'] }),
+  group(6, 'Team Old', { restrict_printers: true, locations: ['Gone'] }),
+];
+
+const users = [
+  { id: 1, username: 'boss', is_active: true, is_admin: true, groups: [{ id: 1, name: 'Administrators' }] },
+  { id: 2, username: 'alice', is_active: true, is_admin: false, groups: [{ id: 3, name: 'Viewers' }, { id: 5, name: 'Team A' }] },
+  { id: 3, username: 'bob', is_active: true, is_admin: false, groups: [{ id: 3, name: 'Viewers' }] },
+];
+
+let patches: Record<number, Record<string, unknown>>;
+
+beforeEach(() => {
+  patches = {};
+  server.use(
+    http.get('/api/v1/groups/', () => HttpResponse.json(groups)),
+    http.get('/api/v1/printers/', () => HttpResponse.json(printers)),
+    http.get('/api/v1/users/', () => HttpResponse.json(users)),
+    http.patch('/api/v1/groups/:id', async ({ request, params }) => {
+      const body = (await request.json()) as Record<string, unknown>;
+      patches[Number(params.id)] = body;
+      return HttpResponse.json({ ...groups.find((g) => g.id === Number(params.id)), ...body });
+    })
+  );
+});
+
+const open = (query: string) => {
+  window.history.pushState({}, '', `/settings?tab=users&sub=printer-access${query}`);
+  return render(<PrinterAccessSettings />);
+};
+
+describe('PrinterAccessSettings by group', () => {
+  it('shows what a group reaches through its locations and its picks', async () => {
+    open('&group=5');
+
+    expect(await screen.findByText('Members can use 3 of 4 printers.')).toBeInTheDocument();
+    // Through Lab A: ticked and fixed
+    expect(screen.getByLabelText('Alpha')).toBeChecked();
+    expect(screen.getByLabelText('Alpha')).toBeDisabled();
+    // Picked on its own
+    expect(screen.getByLabelText('Charlie')).toBeChecked();
+    expect(screen.getByLabelText('Charlie')).toBeEnabled();
+    expect(screen.getByLabelText('Loose')).not.toBeChecked();
+  });
+
+  it('gives a whole location and saves it', async () => {
+    const user = userEvent.setup();
+    open('&group=5');
+    await screen.findByText('Members can use 3 of 4 printers.');
+
+    await user.click(screen.getByLabelText('Every printer in Lab B, including printers added there later'));
+    await user.click(screen.getByRole('button', { name: /Save/ }));
+
+    await waitFor(() => expect(patches[5]).toBeDefined());
+    expect(patches[5]).toEqual({ restrict_printers: true, printer_ids: [3], locations: ['Lab A', 'Lab B'] });
+    expect(patches[6]).toBeUndefined();
+  });
+
+  it('ticks only the printers the filters leave', async () => {
+    const user = userEvent.setup();
+    open('&group=5');
+    await screen.findByText('Members can use 3 of 4 printers.');
+
+    await user.type(screen.getByLabelText('Search name, model, serial or location'), 'loose');
+    await waitFor(() => expect(screen.queryByLabelText('Charlie')).not.toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: 'Tick all shown' }));
+    await user.click(screen.getByRole('button', { name: /Save/ }));
+
+    await waitFor(() => expect(patches[5]).toBeDefined());
+    expect(patches[5].printer_ids).toEqual([3, 4]);
+    expect(patches[5].locations).toEqual(['Lab A']);
+  });
+
+  it('lists a given location no printer has any more, so it can be removed', async () => {
+    const user = userEvent.setup();
+    open('&group=6');
+
+    expect(await screen.findByText('Gone')).toBeInTheDocument();
+    expect(screen.getByText(/won't see any printer/)).toBeInTheDocument();
+    await user.click(screen.getByLabelText('Every printer in Gone, including printers added there later'));
+    await user.click(screen.getByRole('button', { name: /Save/ }));
+
+    await waitFor(() => expect(patches[6]).toBeDefined());
+    expect(patches[6].locations).toEqual([]);
+  });
+
+  it('filters the group list', async () => {
+    const user = userEvent.setup();
+    open('');
+    await screen.findByText('Team A');
+
+    await user.click(screen.getByRole('button', { name: 'Not limited' }));
+    expect(screen.queryByText('Team A')).not.toBeInTheDocument();
+    expect(screen.getByText('Viewers')).toBeInTheDocument();
+
+    await user.click(screen.getByRole('button', { name: 'All' }));
+    await user.type(screen.getByLabelText('Search groups'), 'old');
+    expect(screen.getByText('Team Old')).toBeInTheDocument();
+    expect(screen.queryByText('Viewers')).not.toBeInTheDocument();
+  });
+
+  it('cannot limit Administrators', async () => {
+    open('&group=1');
+    expect(await screen.findByText('Administrators always see every printer.')).toBeInTheDocument();
+    expect(screen.queryByRole('switch')).not.toBeInTheDocument();
+  });
+
+  it('discards drafts', async () => {
+    const user = userEvent.setup();
+    open('&group=5');
+    await screen.findByText('Members can use 3 of 4 printers.');
+
+    await user.click(screen.getByLabelText('Charlie'));
+    expect(screen.getByText('Unsaved changes: Team A')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: 'Discard' }));
+    expect(screen.queryByText(/Unsaved changes:/)).not.toBeInTheDocument();
+    expect(screen.getByLabelText('Charlie')).toBeChecked();
+  });
+});
+
+describe('PrinterAccessSettings by printer', () => {
+  it('shows one printer from the card menu, with who reaches it and who sees everything', async () => {
+    open('&view=printers&printer=3');
+
+    expect(await screen.findByText('Charlie')).toBeInTheDocument();
+    expect(screen.queryByText('Alpha')).not.toBeInTheDocument();
+    expect(screen.getByText('Team A')).toBeInTheDocument();
+    // bob is in no limited group, so he sees every printer
+    expect(await screen.findByText('Users in no limited group: bob')).toBeInTheDocument();
+  });
+
+  it('removes a picked printer from a group', async () => {
+    const user = userEvent.setup();
+    open('&view=printers&printer=3');
+    await screen.findByText('Charlie');
+
+    await user.click(screen.getByRole('button', { name: 'Remove access for Team A' }));
+    await user.click(screen.getByRole('button', { name: /Save/ }));
+
+    await waitFor(() => expect(patches[5]).toBeDefined());
+    expect(patches[5].printer_ids).toEqual([]);
+    expect(patches[5].locations).toEqual(['Lab A']);
+  });
+
+  it('gives a group access to a printer', async () => {
+    const user = userEvent.setup();
+    open('&view=printers&printer=4');
+    await screen.findByText('Loose');
+
+    await user.selectOptions(screen.getByLabelText('Give access to…'), 'Team Old');
+    await user.click(screen.getByRole('button', { name: /Save/ }));
+
+    await waitFor(() => expect(patches[6]).toBeDefined());
+    expect(patches[6].printer_ids).toEqual([4]);
+  });
+
+  it('marks access through a location, which is changed per group', async () => {
+    open('&view=printers&printer=1');
+    await screen.findByText('Alpha');
+
+    expect(screen.getByTitle('Through location Lab A. Change it under By group.')).toHaveTextContent('Team A');
+    expect(screen.queryByRole('button', { name: 'Remove access for Team A' })).not.toBeInTheDocument();
+  });
+});

+ 101 - 0
frontend/src/__tests__/pages/GroupEditPage.test.tsx

@@ -281,3 +281,104 @@ describe('GroupEditPage', () => {
     });
   });
 });
+
+describe('GroupEditPage printer access (#1727)', () => {
+  // Printer access is edited on its own page; the editor only summarises it
+  let posted: Record<string, unknown> | null;
+  let patched: Record<string, unknown> | null;
+
+  const setup = (group: Record<string, unknown>) => {
+    posted = null;
+    patched = null;
+    server.use(
+      http.get('/api/v1/groups/permissions', () => HttpResponse.json(mockPermissions)),
+      http.get('/api/v1/groups/:id', () => HttpResponse.json(group)),
+      http.post('/api/v1/groups/', async ({ request }) => {
+        posted = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ ...group, ...posted, id: 10 });
+      }),
+      http.patch('/api/v1/groups/:id', async ({ request }) => {
+        patched = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ ...group, ...patched });
+      })
+    );
+  };
+
+  const renderEdit = async () => {
+    const { MemoryRouter, Routes, Route } = await import('react-router-dom');
+    const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
+    const { AuthProvider } = await import('../../contexts/AuthContext');
+    const { ToastProvider } = await import('../../contexts/ToastContext');
+    const { ThemeProvider } = await import('../../contexts/ThemeContext');
+    const { render: rtlRender } = await import('@testing-library/react');
+    const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+    rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <AuthProvider>
+          <ThemeProvider>
+            <ToastProvider>
+              <MemoryRouter initialEntries={['/groups/2/edit']}>
+                <Routes>
+                  <Route path="/groups/:id/edit" element={<GroupEditPage />} />
+                  <Route path="/settings" element={<div>Settings</div>} />
+                </Routes>
+              </MemoryRouter>
+            </ToastProvider>
+          </ThemeProvider>
+        </AuthProvider>
+      </QueryClientProvider>
+    );
+  };
+
+  it('summarises a limited group and links to its printer access', async () => {
+    setup({ ...mockGroup, restrict_printers: true, printer_ids: [2, 3], locations: ['Lab A'] });
+    await renderEdit();
+
+    await waitFor(() => expect(screen.getByDisplayValue('Operators')).toBeInTheDocument());
+    expect(screen.getByText('Limited: 2 printers picked, 1 locations')).toBeInTheDocument();
+    expect(screen.getByRole('link', { name: 'Manage printer access' })).toHaveAttribute(
+      'href',
+      '/settings?tab=users&sub=printer-access&group=2'
+    );
+    expect(screen.queryByRole('switch')).not.toBeInTheDocument();
+  });
+
+  it('never sends printer access, so saving here cannot undo the access page', async () => {
+    setup({ ...mockGroup, restrict_printers: true, printer_ids: [2], locations: ['Lab A'] });
+    const user = userEvent.setup();
+    await renderEdit();
+
+    await waitFor(() => expect(screen.getByDisplayValue('Operators')).toBeInTheDocument());
+    await user.click(screen.getByText('Save'));
+
+    await waitFor(() => expect(patched).not.toBeNull());
+    expect(patched).not.toHaveProperty('restrict_printers');
+    expect(patched).not.toHaveProperty('printer_ids');
+    expect(patched).not.toHaveProperty('locations');
+    // System group: unchanged permissions aren't resent either
+    expect(patched).not.toHaveProperty('permissions');
+  });
+
+  it('points a new group to the access page', async () => {
+    setup({});
+    const user = userEvent.setup();
+    render(<GroupEditPage />);
+
+    await waitFor(() => expect(screen.getByText('Printer access')).toBeInTheDocument());
+    expect(screen.getByText(/Once the group is created/)).toBeInTheDocument();
+    await user.type(screen.getByPlaceholderText(/group name/i), 'Team A');
+    await user.click(screen.getByText('Save'));
+
+    await waitFor(() => expect(posted).not.toBeNull());
+    expect(posted).not.toHaveProperty('restrict_printers');
+  });
+
+  it('explains that Administrators always see every printer', async () => {
+    setup({ ...mockGroup, id: 1, name: 'Administrators', restrict_printers: false, printer_ids: [], locations: [] });
+    await renderEdit();
+
+    await waitFor(() => expect(screen.getByDisplayValue('Administrators')).toBeInTheDocument());
+    expect(screen.getByText('Administrators always see every printer.')).toBeInTheDocument();
+    expect(screen.queryByRole('link', { name: 'Manage printer access' })).not.toBeInTheDocument();
+  });
+});

+ 39 - 1
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -2,13 +2,14 @@
  * Tests for the QueuePage component.
  */
 
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { screen, waitFor, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { render } from '../utils';
 import { QueuePage } from '../../pages/QueuePage';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
+import { setAuthToken } from '../../api/client';
 
 // Mock queue data
 const mockQueueItems = [
@@ -1457,6 +1458,43 @@ describe('QueuePage', () => {
         expect(screen.getByTitle('Start Print')).toBeInTheDocument();
       });
     });
+
+    // Without queue:start_unreviewed their jobs wait for staff to start them (#1620)
+    describe('waiting for review', () => {
+      const signInWith = (permissions: string[]) => {
+        setAuthToken('test-token', 'session');
+        server.use(
+          http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+          http.get('*/api/v1/auth/me', () =>
+            HttpResponse.json({ id: 7, username: 'student', is_admin: false, permissions }),
+          ),
+          http.get('/api/v1/queue/', () =>
+            HttpResponse.json([{ ...mockQueueItems[0], manual_start: true, created_by_id: 7 }]),
+          ),
+        );
+      };
+
+      afterEach(() => {
+        setAuthToken(null);
+      });
+
+      it('tells a student their job waits for review and offers no start', async () => {
+        signInWith(['queue:read_own', 'queue:update_own', 'queue:delete_own']);
+        render(<QueuePage />);
+
+        expect(await screen.findByText('Waiting for review')).toBeInTheDocument();
+        const start = screen.getByTitle('Waiting for review: someone who manages the queue starts this job');
+        expect(start).toBeDisabled();
+      });
+
+      it('keeps the start button for users who may print without review', async () => {
+        signInWith(['queue:read_own', 'queue:update_own', 'queue:start_unreviewed']);
+        render(<QueuePage />);
+
+        expect(await screen.findByText('Staged')).toBeInTheDocument();
+        expect(screen.getByTitle('Start Print')).not.toBeDisabled();
+      });
+    });
   });
 
   describe('auto power off badge', () => {

+ 44 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -2032,6 +2032,50 @@ describe('SettingsPage', () => {
     });
   });
 
+  describe('printer access sub-tab (#1727)', () => {
+    const asAdmin = (isAdmin: boolean) => {
+      setAuthToken('test-token', 'session');
+      server.use(
+        http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+        http.get('*/api/v1/auth/me', () =>
+          HttpResponse.json({ id: 1, username: 'u', is_admin: isAdmin, groups: [], permissions: ['settings:read', 'users:read', 'groups:read'] })
+        ),
+        http.get('/api/v1/groups/', () => HttpResponse.json([])),
+        http.get('/api/v1/users/', () => HttpResponse.json([]))
+      );
+    };
+
+    it('opens straight on Printer access from a deep link', async () => {
+      asAdmin(true);
+      window.history.replaceState({}, '', '/settings?tab=users&sub=printer-access&view=printers');
+      render(<SettingsPage />);
+
+      expect(await screen.findByText(/Besides the groups listed here/)).toBeInTheDocument();
+    });
+
+    it('drops the sub-tab from the URL when leaving it', async () => {
+      asAdmin(true);
+      window.history.replaceState({}, '', '/settings?tab=users&sub=printer-access&group=4');
+      render(<SettingsPage />);
+      const user = userEvent.setup();
+      await screen.findByText(/Pick a group to choose its printers/);
+
+      await user.click(screen.getByRole('button', { name: 'General' }));
+      await waitFor(() => expect(window.location.search).toBe(''));
+    });
+
+    it('lands non-admins on the Users sub-tab instead', async () => {
+      asAdmin(false);
+      window.history.replaceState({}, '', '/settings?tab=users&sub=printer-access');
+      render(<SettingsPage />);
+
+      // The sub-tab row is there, without Printer access
+      expect(await screen.findByRole('button', { name: /Two-Factor|2FA/i })).toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: 'Printer access' })).not.toBeInTheDocument();
+      expect(screen.queryByText(/Pick a group to choose its printers/)).not.toBeInTheDocument();
+    });
+  });
+
   // --------------------------------------------------------------------
   // Ask for the outcome of prints Bambuddy did not start (#1898)
   // --------------------------------------------------------------------

+ 13 - 2
frontend/src/api/client.ts

@@ -749,7 +749,7 @@ export interface PrinterCreate {
   ip_address: string;
   access_code: string;
   model?: string;
-  location?: string;
+  location?: string | null;
   auto_archive?: boolean;
   // Maintenance Mode flag (#1476). Backend already gates MQTT, queue dispatch,
   // scheduler, metrics and the print picker on this; toggling via PATCH
@@ -4345,7 +4345,7 @@ export type Permission =
   | 'archives:reprint_own' | 'archives:reprint_all' | 'archives:purge'
   | 'queue:read' | 'queue:read_own' | 'queue:read_all' | 'queue:create'
   | 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'
-  | 'queue:reorder'
+  | 'queue:reorder' | 'queue:start_unreviewed'
   | 'library:read' | 'library:read_own' | 'library:read_all' | 'library:upload'
   | 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
   | 'library:purge'
@@ -4389,6 +4389,11 @@ export interface Group {
   description: string | null;
   permissions: Permission[];
   is_system: boolean;
+  /** Members only see the printers in printer_ids plus every printer in locations (#1727) */
+  restrict_printers: boolean;
+  printer_ids: number[];
+  /** Matched against Printer.location, so printers added there later are included */
+  locations: string[];
   user_count: number;
   created_at: string;
   updated_at: string;
@@ -4402,12 +4407,18 @@ export interface GroupCreate {
   name: string;
   description?: string;
   permissions: Permission[];
+  restrict_printers?: boolean;
+  printer_ids?: number[];
+  locations?: string[];
 }
 
 export interface GroupUpdate {
   name?: string;
   description?: string;
   permissions?: Permission[];
+  restrict_printers?: boolean;
+  printer_ids?: number[];
+  locations?: string[];
 }
 
 export interface PermissionInfo {

+ 10 - 1
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -29,6 +29,7 @@ export function ScheduleOptionsPanel({
   showStagger = false,
   printerCount = 0,
   hasGcodeSnippets = false,
+  needsReview = false,
 }: ScheduleOptionsProps) {
   const { t } = useTranslation();
   const [dateValue, setDateValue] = useState('');
@@ -227,8 +228,16 @@ export function ScheduleOptionsPanel({
         </div>
       )}
 
+      {/* Their jobs always wait, so the checkbox would only mislead (#1620) */}
+      {needsReview && (
+        <p className="flex items-start gap-1.5 text-sm text-bambu-gray">
+          <Hand className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
+          {t('printModal.awaitingReviewNote')}
+        </p>
+      )}
+
       {/* Manual start */}
-      {options.scheduleType === 'queue' && (
+      {!needsReview && options.scheduleType === 'queue' && (
         <div className="flex items-center gap-2">
           <input
             type="checkbox"

+ 18 - 5
frontend/src/components/PrintModal/index.tsx

@@ -99,7 +99,10 @@ export function PrintModal({
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
-  const { hasPermission, user } = useAuth();
+  const { hasPermission, hasAnyPermission, user } = useAuth();
+  // Their jobs wait for someone to start them (#1620); the server enforces it,
+  // this keeps the request and the dialog honest about it.
+  const needsReview = !hasAnyPermission('queue:start_unreviewed', 'queue:update_all');
 
   // Determine if we're printing a library file
   const isLibraryFile = !!libraryFileId && !archiveId;
@@ -989,6 +992,15 @@ export function PrintModal({
     });
   };
 
+  // Saving an existing job. Only Queue has a manual-start checkbox, so a
+  // scheduled job keeps whatever wait it had: a student's scheduled job waits
+  // for review (#1620), and editing it must not start it.
+  const editManualStart =
+    needsReview ||
+    (scheduleOptions.scheduleType === 'queue'
+      ? scheduleOptions.requireManualStart
+      : scheduleOptions.scheduleType === 'scheduled' && !!queueItem?.manual_start);
+
   const handleSubmit = async (e?: React.FormEvent, options?: { skipFilamentCheck?: boolean }) => {
     e?.preventDefault();
 
@@ -1238,7 +1250,7 @@ export function PrintModal({
           require_previous_success: scheduleOptions.requirePreviousSuccess,
           auto_off_after: scheduleOptions.autoOffAfter,
           gcode_injection: scheduleOptions.gcodeInjection,
-          manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
+          manual_start: needsReview || (scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart),
           scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
             ? new Date(scheduleOptions.scheduledTime).toISOString()
             : undefined,
@@ -1339,7 +1351,7 @@ export function PrintModal({
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       auto_off_after: scheduleOptions.autoOffAfter,
       gcode_injection: scheduleOptions.gcodeInjection,
-      manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
+      manual_start: needsReview || (scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart),
       // When the user clicks "Print Anyway" on the frontend deficit warning,
       // persist that acknowledgement so the scheduler doesn't immediately
       // re-flag the item on its first dispatch tick (#1698-followup).
@@ -1386,7 +1398,7 @@ export function PrintModal({
               require_previous_success: scheduleOptions.requirePreviousSuccess,
               auto_off_after: scheduleOptions.autoOffAfter,
               gcode_injection: scheduleOptions.gcodeInjection,
-              manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
+              manual_start: editManualStart,
               ams_mapping: undefined,
               plate_id: plateId,
               scheduled_time: scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
@@ -1448,7 +1460,7 @@ export function PrintModal({
                 require_previous_success: scheduleOptions.requirePreviousSuccess,
                 auto_off_after: scheduleOptions.autoOffAfter,
                 gcode_injection: scheduleOptions.gcodeInjection,
-                manual_start: scheduleOptions.scheduleType === 'queue' && scheduleOptions.requireManualStart,
+                manual_start: editManualStart,
                 ams_mapping: printerMapping,
                 // Only ever set here, never cleared: an earlier "Print Anyway"
                 // stays acknowledged across an edit, as it does today.
@@ -2059,6 +2071,7 @@ export function PrintModal({
               showStagger={!isEditing && assignmentMode === 'printer' && selectedPrinters.length > 1}
               printerCount={selectedPrinters.length}
               hasGcodeSnippets={!!settings?.gcode_snippets}
+              needsReview={needsReview}
             />
 
             {/* Outcome prompt (#1898) sits outside the collapsed Print Options

+ 2 - 0
frontend/src/components/PrintModal/types.ts

@@ -328,4 +328,6 @@ export interface ScheduleOptionsProps {
   printerCount?: number;
   /** Whether G-code snippets are configured in settings */
   hasGcodeSnippets?: boolean;
+  /** The user's jobs wait for someone to start them (#1620) */
+  needsReview?: boolean;
 }

+ 787 - 0
frontend/src/components/PrinterAccessSettings.tsx

@@ -0,0 +1,787 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Link, useSearchParams } from 'react-router-dom';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  AlertTriangle,
+  ChevronDown,
+  ChevronRight,
+  Loader2,
+  MapPin,
+  Pencil,
+  Printer as PrinterIcon,
+  Save,
+  Search,
+  Users,
+  X,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { Group, Printer } from '../api/client';
+import { Button } from './Button';
+import { Card } from './Card';
+import { Toggle } from './Toggle';
+import { useToast } from '../contexts/ToastContext';
+
+/**
+ * Which printers each group may use (#1727).
+ *
+ * A limited group reaches the printers picked for it plus every printer whose
+ * location it was given, including printers added there later. Edits collect
+ * as per-group drafts, from either view, and are saved together.
+ */
+
+interface Draft {
+  restrict: boolean;
+  printerIds: number[];
+  locations: string[];
+}
+
+type View = 'groups' | 'printers';
+type GroupFilter = 'all' | 'limited' | 'open';
+type AccessFilter = 'all' | 'with' | 'without';
+
+const NO_LOCATION = '__none__';
+// Past this many printers, location sections start collapsed
+const AUTO_COLLAPSE_OVER = 60;
+
+const inputClass =
+  'px-3 py-2 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green';
+
+const isAdministrators = (group: Group) => group.is_system && group.name === 'Administrators';
+
+const draftOf = (group: Group): Draft => ({
+  restrict: group.restrict_printers,
+  printerIds: [...group.printer_ids].sort((a, b) => a - b),
+  locations: [...(group.locations ?? [])].sort(),
+});
+
+const sameDraft = (a: Draft, b: Draft) =>
+  a.restrict === b.restrict &&
+  a.printerIds.length === b.printerIds.length &&
+  a.printerIds.every((id, i) => id === b.printerIds[i]) &&
+  a.locations.length === b.locations.length &&
+  a.locations.every((loc, i) => loc === b.locations[i]);
+
+const locationKey = (printer: Printer) => printer.location || NO_LOCATION;
+
+/** How a draft reaches a printer: through its location, picked, or not at all. */
+function reachOf(draft: Draft, printer: Printer): 'location' | 'picked' | null {
+  if (printer.location && draft.locations.includes(printer.location)) return 'location';
+  if (draft.printerIds.includes(printer.id)) return 'picked';
+  return null;
+}
+
+export function PrinterAccessSettings() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const view: View = searchParams.get('view') === 'printers' ? 'printers' : 'groups';
+  const selectedGroupId = Number(searchParams.get('group')) || null;
+  const focusPrinterId = Number(searchParams.get('printer')) || null;
+
+  const setParams = (changes: Record<string, string | null>) => {
+    setSearchParams(
+      (prev) => {
+        const next = new URLSearchParams(prev);
+        for (const [key, value] of Object.entries(changes)) {
+          if (value === null) next.delete(key);
+          else next.set(key, value);
+        }
+        return next;
+      },
+      { replace: true }
+    );
+  };
+
+  const [drafts, setDrafts] = useState<Record<number, Draft>>({});
+  const [groupSearch, setGroupSearch] = useState('');
+  const [groupFilter, setGroupFilter] = useState<GroupFilter>('all');
+  const [printerSearch, setPrinterSearch] = useState('');
+  const [locationFilter, setLocationFilter] = useState('all');
+  const [modelFilter, setModelFilter] = useState('all');
+  const [accessFilter, setAccessFilter] = useState<AccessFilter>('all');
+  const [sectionOpen, setSectionOpen] = useState<Record<string, boolean>>({});
+
+  const { data: groups, isLoading: groupsLoading } = useQuery({
+    queryKey: ['groups'],
+    queryFn: () => api.getGroups(),
+  });
+  const { data: printers, isLoading: printersLoading } = useQuery({
+    queryKey: ['printers'],
+    queryFn: () => api.getPrinters(),
+  });
+  const { data: users } = useQuery({
+    queryKey: ['users'],
+    queryFn: () => api.getUsers(),
+  });
+
+  const draftFor = (group: Group): Draft => drafts[group.id] ?? draftOf(group);
+
+  const updateDraft = (group: Group, change: (draft: Draft) => Draft) => {
+    setDrafts((prev) => {
+      const next = change(prev[group.id] ?? draftOf(group));
+      const normalized: Draft = {
+        restrict: next.restrict,
+        printerIds: [...new Set(next.printerIds)].sort((a, b) => a - b),
+        locations: [...new Set(next.locations)].sort(),
+      };
+      const rest = { ...prev };
+      if (sameDraft(normalized, draftOf(group))) delete rest[group.id];
+      else rest[group.id] = normalized;
+      return rest;
+    });
+  };
+
+  const dirtyGroups = useMemo(
+    () => (groups ?? []).filter((g) => drafts[g.id] !== undefined),
+    [groups, drafts]
+  );
+  const isDirty = dirtyGroups.length > 0;
+
+  useEffect(() => {
+    if (!isDirty) return;
+    const warn = (e: BeforeUnloadEvent) => {
+      e.preventDefault();
+    };
+    window.addEventListener('beforeunload', warn);
+    return () => window.removeEventListener('beforeunload', warn);
+  }, [isDirty]);
+
+  const saveMutation = useMutation({
+    mutationFn: async () => {
+      // One group after the other, so a refusal leaves the rest as drafts
+      for (const group of dirtyGroups) {
+        const draft = drafts[group.id];
+        await api.updateGroup(group.id, {
+          restrict_printers: draft.restrict,
+          printer_ids: draft.printerIds,
+          locations: draft.locations,
+        });
+        setDrafts((prev) => {
+          const rest = { ...prev };
+          delete rest[group.id];
+          return rest;
+        });
+      }
+    },
+    onSuccess: () => showToast(t('printerAccess.saved')),
+    onError: (error: Error) => showToast(error.message, 'error'),
+    onSettled: () => {
+      queryClient.invalidateQueries({ queryKey: ['groups'] });
+      queryClient.invalidateQueries({ queryKey: ['group'] });
+    },
+  });
+
+  const allPrinters = useMemo(
+    () => [...(printers ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
+    [printers]
+  );
+
+  const locations = useMemo(() => {
+    const names = new Set<string>();
+    for (const p of allPrinters) if (p.location) names.add(p.location);
+    return [...names].sort((a, b) => a.localeCompare(b));
+  }, [allPrinters]);
+  const hasUnlocated = allPrinters.some((p) => !p.location);
+
+  const models = useMemo(() => {
+    const names = new Set<string>();
+    for (const p of allPrinters) if (p.model) names.add(p.model);
+    return [...names].sort();
+  }, [allPrinters]);
+
+  const printersByLocation = useMemo(() => {
+    const map = new Map<string, Printer[]>();
+    for (const p of allPrinters) {
+      const key = locationKey(p);
+      map.set(key, [...(map.get(key) ?? []), p]);
+    }
+    return map;
+  }, [allPrinters]);
+
+  // Printers left after search, location and model filters (not the access filter)
+  const baseFiltered = useMemo(() => {
+    const q = printerSearch.trim().toLowerCase();
+    return allPrinters.filter((p) => {
+      if (locationFilter !== 'all' && locationKey(p) !== locationFilter) return false;
+      if (modelFilter !== 'all' && p.model !== modelFilter) return false;
+      if (!q) return true;
+      return (
+        p.name.toLowerCase().includes(q) ||
+        (p.model ?? '').toLowerCase().includes(q) ||
+        p.serial_number.toLowerCase().includes(q) ||
+        (p.location ?? '').toLowerCase().includes(q)
+      );
+    });
+  }, [allPrinters, printerSearch, locationFilter, modelFilter]);
+
+  const sortedGroups = useMemo(() => [...(groups ?? [])].sort((a, b) => a.name.localeCompare(b.name)), [groups]);
+
+  const groupQuery = groupSearch.trim().toLowerCase();
+  const visibleGroups = sortedGroups.filter((g) => {
+    if (groupQuery && !g.name.toLowerCase().includes(groupQuery)) return false;
+    const limited = !isAdministrators(g) && draftFor(g).restrict;
+    if (groupFilter === 'limited') return limited;
+    if (groupFilter === 'open') return !limited;
+    return true;
+  });
+
+  const limitedGroups = sortedGroups.filter((g) => !isAdministrators(g) && draftFor(g).restrict);
+
+  const reachCount = (draft: Draft) => allPrinters.filter((p) => reachOf(draft, p) !== null).length;
+
+  if (groupsLoading || printersLoading) {
+    return (
+      <div className="flex items-center justify-center py-16">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  const selectedGroup = sortedGroups.find((g) => g.id === selectedGroupId) ?? null;
+
+  const filterBar = (withAccessFilter: boolean) => (
+    <div className="flex flex-wrap items-center gap-2">
+      <div className="relative flex-1 min-w-[12rem]">
+        <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray" />
+        <input
+          type="text"
+          value={printerSearch}
+          onChange={(e) => setPrinterSearch(e.target.value)}
+          placeholder={t('printerAccess.searchPrinters')}
+          aria-label={t('printerAccess.searchPrinters')}
+          className={`${inputClass} w-full pl-9`}
+        />
+      </div>
+      <select
+        value={locationFilter}
+        onChange={(e) => setLocationFilter(e.target.value)}
+        aria-label={t('printerAccess.allLocations')}
+        className={inputClass}
+      >
+        <option value="all">{t('printerAccess.allLocations')}</option>
+        {locations.map((loc) => (
+          <option key={loc} value={loc}>
+            {loc}
+          </option>
+        ))}
+        {hasUnlocated && <option value={NO_LOCATION}>{t('printerAccess.noLocation')}</option>}
+      </select>
+      {models.length > 1 && (
+        <select
+          value={modelFilter}
+          onChange={(e) => setModelFilter(e.target.value)}
+          aria-label={t('printerAccess.allModels')}
+          className={inputClass}
+        >
+          <option value="all">{t('printerAccess.allModels')}</option>
+          {models.map((m) => (
+            <option key={m} value={m}>
+              {m}
+            </option>
+          ))}
+        </select>
+      )}
+      {withAccessFilter && (
+        <select
+          value={accessFilter}
+          onChange={(e) => setAccessFilter(e.target.value as AccessFilter)}
+          aria-label={t('printerAccess.accessFilter')}
+          className={inputClass}
+        >
+          <option value="all">{t('printerAccess.accessAll')}</option>
+          <option value="with">{t('printerAccess.accessWith')}</option>
+          <option value="without">{t('printerAccess.accessWithout')}</option>
+        </select>
+      )}
+    </div>
+  );
+
+  const renderGroupList = () => (
+    <Card className="lg:w-80 shrink-0 flex flex-col lg:max-h-[calc(100vh-14rem)]">
+      <div className="p-3 space-y-2 border-b border-bambu-dark-tertiary">
+        <div className="relative">
+          <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-bambu-gray" />
+          <input
+            type="text"
+            value={groupSearch}
+            onChange={(e) => setGroupSearch(e.target.value)}
+            placeholder={t('printerAccess.searchGroups')}
+            aria-label={t('printerAccess.searchGroups')}
+            className={`${inputClass} w-full pl-9`}
+          />
+        </div>
+        <div className="flex gap-1">
+          {(['all', 'limited', 'open'] as const).map((f) => (
+            <button
+              key={f}
+              type="button"
+              onClick={() => setGroupFilter(f)}
+              className={`flex-1 px-2 py-1 text-xs rounded-md transition-colors ${
+                groupFilter === f
+                  ? 'bg-bambu-green/20 text-bambu-green'
+                  : 'text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+              }`}
+            >
+              {t(`printerAccess.groupFilter.${f}`)}
+            </button>
+          ))}
+        </div>
+      </div>
+      <div className="overflow-y-auto p-1">
+        {visibleGroups.length === 0 ? (
+          <p className="text-sm text-bambu-gray p-3">{t('printerAccess.noGroupsMatch')}</p>
+        ) : (
+          visibleGroups.map((g) => {
+            const draft = draftFor(g);
+            const admins = isAdministrators(g);
+            const summary = admins
+              ? t('printerAccess.alwaysAll')
+              : draft.restrict
+              ? t('printerAccess.reachShort', { count: reachCount(draft), total: allPrinters.length })
+              : t('printerAccess.notLimited');
+            return (
+              <button
+                key={g.id}
+                type="button"
+                onClick={() => setParams({ group: String(g.id) })}
+                className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
+                  g.id === selectedGroupId ? 'bg-bambu-dark-tertiary' : 'hover:bg-bambu-dark-tertiary/60'
+                }`}
+              >
+                <div className="flex items-center gap-2">
+                  <span className="text-sm text-white truncate flex-1">{g.name}</span>
+                  {drafts[g.id] && (
+                    <span className="w-2 h-2 rounded-full bg-yellow-400 shrink-0" title={t('printerAccess.unsaved')} />
+                  )}
+                </div>
+                <div className="flex items-center gap-2 text-xs text-bambu-gray mt-0.5">
+                  <Users className="w-3 h-3 shrink-0" />
+                  <span>{g.user_count}</span>
+                  <span>·</span>
+                  <span className={!admins && draft.restrict ? 'text-bambu-green' : ''}>{summary}</span>
+                </div>
+              </button>
+            );
+          })
+        )}
+      </div>
+    </Card>
+  );
+
+  const renderGroupEditor = () => {
+    if (!selectedGroup) {
+      return (
+        <Card className="flex-1 p-8 text-center text-sm text-bambu-gray">{t('printerAccess.pickGroup')}</Card>
+      );
+    }
+    const group = selectedGroup;
+    const draft = draftFor(group);
+    const reached = reachCount(draft);
+
+    const shown = baseFiltered.filter((p) => {
+      const reach = reachOf(draft, p);
+      if (accessFilter === 'with') return reach !== null;
+      if (accessFilter === 'without') return reach === null;
+      return true;
+    });
+    const shownByLocation = new Map<string, Printer[]>();
+    for (const p of shown) {
+      const key = locationKey(p);
+      shownByLocation.set(key, [...(shownByLocation.get(key) ?? []), p]);
+    }
+    const sectionKeys = [...shownByLocation.keys()].sort((a, b) =>
+      a === NO_LOCATION ? 1 : b === NO_LOCATION ? -1 : a.localeCompare(b)
+    );
+    // Locations given to the group that no printer carries any more (renamed, emptied)
+    const staleLocations = draft.locations.filter((loc) => !printersByLocation.has(loc));
+    const autoCollapsed = shown.length > AUTO_COLLAPSE_OVER && !printerSearch.trim();
+    const isOpen = (key: string) => sectionOpen[key] ?? !autoCollapsed;
+
+    const toggleLocation = (loc: string, on: boolean) =>
+      updateDraft(group, (d) => ({
+        ...d,
+        locations: on ? [...d.locations, loc] : d.locations.filter((l) => l !== loc),
+      }));
+    const togglePrinter = (id: number, on: boolean) =>
+      updateDraft(group, (d) => ({
+        ...d,
+        printerIds: on ? [...d.printerIds, id] : d.printerIds.filter((p) => p !== id),
+      }));
+    const pickShown = (on: boolean) => {
+      const ids = shown.filter((p) => reachOf(draft, p) !== 'location').map((p) => p.id);
+      updateDraft(group, (d) => ({
+        ...d,
+        printerIds: on ? [...d.printerIds, ...ids] : d.printerIds.filter((id) => !ids.includes(id)),
+      }));
+    };
+
+    return (
+      <Card className="flex-1 min-w-0">
+        <div className="p-4 space-y-4">
+          <div className="flex items-start justify-between gap-3">
+            <div className="min-w-0">
+              <h3 className="text-white font-medium truncate">{group.name}</h3>
+              <p className="text-xs text-bambu-gray mt-0.5">
+                {t('printerAccess.memberCount', { count: group.user_count })}
+              </p>
+            </div>
+            <Link
+              to={`/groups/${group.id}/edit`}
+              className="flex items-center gap-1.5 text-xs text-bambu-gray hover:text-white shrink-0"
+            >
+              <Pencil className="w-3.5 h-3.5" />
+              {t('printerAccess.editGroup')}
+            </Link>
+          </div>
+
+          {isAdministrators(group) ? (
+            <p className="text-sm text-bambu-gray">{t('printerAccess.adminsSeeAll')}</p>
+          ) : (
+            <>
+              <div className="flex items-start justify-between gap-4">
+                <div>
+                  <p className="text-sm text-white">{t('printerAccess.limit')}</p>
+                  <p className="text-xs text-bambu-gray mt-1">{t('printerAccess.limitHint')}</p>
+                </div>
+                <Toggle
+                  checked={draft.restrict}
+                  onChange={(on) => updateDraft(group, (d) => ({ ...d, restrict: on }))}
+                />
+              </div>
+
+              {draft.restrict && (
+                <>
+                  <div className="flex flex-wrap items-center gap-3">
+                    <span className="text-sm text-bambu-gray">
+                      {t('printerAccess.reach', { count: reached, total: allPrinters.length })}
+                    </span>
+                    {reached === 0 && (
+                      <span className="flex items-center gap-1.5 text-xs text-yellow-700 dark:text-yellow-400">
+                        <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
+                        {t('printerAccess.noneGranted')}
+                      </span>
+                    )}
+                  </div>
+
+                  {allPrinters.length === 0 ? (
+                    <p className="text-sm text-bambu-gray">{t('printerAccess.noPrinters')}</p>
+                  ) : (
+                    <>
+                      {filterBar(true)}
+                      <div className="flex items-center gap-2">
+                        <Button size="sm" variant="ghost" onClick={() => pickShown(true)} disabled={shown.length === 0}>
+                          {t('printerAccess.tickShown')}
+                        </Button>
+                        <Button size="sm" variant="ghost" onClick={() => pickShown(false)} disabled={shown.length === 0}>
+                          {t('printerAccess.untickShown')}
+                        </Button>
+                      </div>
+
+                      {staleLocations.map((loc) => (
+                        <div
+                          key={`stale-${loc}`}
+                          className="flex items-center justify-between gap-3 px-3 py-2 rounded-lg border border-dashed border-bambu-dark-tertiary"
+                        >
+                          <span className="flex items-center gap-2 text-sm text-bambu-gray min-w-0">
+                            <MapPin className="w-4 h-4 shrink-0" />
+                            <span className="truncate">{loc}</span>
+                            <span className="text-xs">({t('printerAccess.emptyLocation')})</span>
+                          </span>
+                          <label className="flex items-center gap-2 text-xs text-bambu-gray cursor-pointer shrink-0">
+                            <input
+                              type="checkbox"
+                              checked
+                              onChange={() => toggleLocation(loc, false)}
+                              aria-label={t('printerAccess.wholeLocationHint', { location: loc })}
+                              className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
+                            />
+                            {t('printerAccess.wholeLocation')}
+                          </label>
+                        </div>
+                      ))}
+
+                      {sectionKeys.length === 0 ? (
+                        <p className="text-sm text-bambu-gray py-4 text-center">{t('printerAccess.noPrintersMatch')}</p>
+                      ) : (
+                        <div className="space-y-2">
+                          {sectionKeys.map((key) => {
+                            const rows = shownByLocation.get(key) ?? [];
+                            const all = printersByLocation.get(key) ?? [];
+                            const inLocation = all.filter((p) => reachOf(draft, p) !== null).length;
+                            const isLocation = key !== NO_LOCATION;
+                            const granted = isLocation && draft.locations.includes(key);
+                            const open = isOpen(key);
+                            return (
+                              <div key={key} className="rounded-lg border border-bambu-dark-tertiary">
+                                <div className="flex items-center gap-3 px-3 py-2">
+                                  <button
+                                    type="button"
+                                    onClick={() => setSectionOpen((prev) => ({ ...prev, [key]: !open }))}
+                                    className="flex items-center gap-2 flex-1 min-w-0 text-left"
+                                    aria-expanded={open}
+                                  >
+                                    {open ? (
+                                      <ChevronDown className="w-4 h-4 text-bambu-gray shrink-0" />
+                                    ) : (
+                                      <ChevronRight className="w-4 h-4 text-bambu-gray shrink-0" />
+                                    )}
+                                    <MapPin className="w-4 h-4 text-bambu-gray shrink-0" />
+                                    <span className="text-sm text-white truncate">
+                                      {isLocation ? key : t('printerAccess.noLocation')}
+                                    </span>
+                                    <span className="text-xs text-bambu-gray tabular-nums shrink-0">
+                                      {inLocation}/{all.length}
+                                    </span>
+                                  </button>
+                                  {isLocation && (
+                                    <label
+                                      className="flex items-center gap-2 text-xs text-bambu-gray cursor-pointer shrink-0"
+                                      title={t('printerAccess.wholeLocationHint', { location: key })}
+                                    >
+                                      <input
+                                        type="checkbox"
+                                        checked={granted}
+                                        onChange={(e) => toggleLocation(key, e.target.checked)}
+                                        aria-label={t('printerAccess.wholeLocationHint', { location: key })}
+                                        className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
+                                      />
+                                      {t('printerAccess.wholeLocation')}
+                                    </label>
+                                  )}
+                                </div>
+                                {open && (
+                                  <div className="grid grid-cols-1 md:grid-cols-2 gap-1 px-2 pb-2">
+                                    {rows.map((p) => {
+                                      const reach = reachOf(draft, p);
+                                      return (
+                                        <label
+                                          key={p.id}
+                                          className={`flex items-center gap-3 px-2 py-1.5 rounded ${
+                                            reach === 'location' ? 'cursor-default' : 'cursor-pointer hover:bg-bambu-dark-tertiary/60'
+                                          }`}
+                                        >
+                                          <input
+                                            type="checkbox"
+                                            checked={reach !== null}
+                                            disabled={reach === 'location'}
+                                            onChange={(e) => togglePrinter(p.id, e.target.checked)}
+                                            aria-label={p.name}
+                                            className="w-4 h-4 shrink-0 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary disabled:opacity-60"
+                                          />
+                                          <span className="min-w-0 flex-1">
+                                            <span className="block text-sm text-white truncate">{p.name}</span>
+                                            <span className="block text-xs text-bambu-gray truncate">
+                                              {[p.model, p.serial_number].filter(Boolean).join(' · ')}
+                                            </span>
+                                          </span>
+                                          {reach === 'location' && (
+                                            <span className="text-xs text-bambu-green shrink-0">
+                                              {t('printerAccess.viaLocation')}
+                                            </span>
+                                          )}
+                                        </label>
+                                      );
+                                    })}
+                                  </div>
+                                )}
+                              </div>
+                            );
+                          })}
+                        </div>
+                      )}
+                    </>
+                  )}
+                </>
+              )}
+            </>
+          )}
+        </div>
+      </Card>
+    );
+  };
+
+  const renderByPrinter = () => {
+    const limitedIds = new Set(limitedGroups.map((g) => g.id));
+    // Who sees every printer whatever the groups below say
+    const openUsers = (users ?? []).filter(
+      (u) => u.is_active && !u.is_admin && !u.groups.some((g) => limitedIds.has(g.id))
+    );
+    const rows = focusPrinterId ? allPrinters.filter((p) => p.id === focusPrinterId) : baseFiltered;
+
+    return (
+      <div className="space-y-3">
+        <Card>
+          <div className="p-4 space-y-1 text-sm">
+            <p className="text-bambu-gray">{t('printerAccess.alsoVisible')}</p>
+            {users && (
+              <p className={openUsers.length > 0 ? 'text-yellow-700 dark:text-yellow-400' : 'text-bambu-gray'}>
+                {openUsers.length > 0
+                  ? t('printerAccess.openUsers', {
+                      names:
+                        openUsers
+                          .slice(0, 10)
+                          .map((u) => u.username)
+                          .join(', ') + (openUsers.length > 10 ? ` +${openUsers.length - 10}` : ''),
+                    })
+                  : t('printerAccess.noOpenUsers')}
+              </p>
+            )}
+          </div>
+        </Card>
+
+        {limitedGroups.length === 0 && (
+          <p className="text-sm text-bambu-gray">{t('printerAccess.noLimitedGroups')}</p>
+        )}
+
+        {focusPrinterId ? (
+          <div className="flex items-center gap-2 text-sm text-bambu-gray">
+            <span>{t('printerAccess.showingOne')}</span>
+            <Button size="sm" variant="ghost" onClick={() => setParams({ printer: null })}>
+              {t('printerAccess.showAll')}
+            </Button>
+          </div>
+        ) : (
+          filterBar(false)
+        )}
+
+        {rows.length === 0 ? (
+          <p className="text-sm text-bambu-gray py-4 text-center">
+            {allPrinters.length === 0 ? t('printerAccess.noPrinters') : t('printerAccess.noPrintersMatch')}
+          </p>
+        ) : (
+          <Card>
+            <div className="divide-y divide-bambu-dark-tertiary">
+              {rows.map((p) => {
+                const reaching = limitedGroups
+                  .map((g) => ({ group: g, reach: reachOf(draftFor(g), p) }))
+                  .filter((r) => r.reach !== null);
+                const addable = limitedGroups.filter((g) => reachOf(draftFor(g), p) === null);
+                return (
+                  <div key={p.id} className="flex flex-col md:flex-row md:items-center gap-2 md:gap-4 px-4 py-3">
+                    <div className="md:w-64 shrink-0 min-w-0">
+                      <div className="flex items-center gap-2">
+                        <PrinterIcon className="w-4 h-4 text-bambu-gray shrink-0" />
+                        <span className="text-sm text-white truncate">{p.name}</span>
+                      </div>
+                      <div className="text-xs text-bambu-gray truncate pl-6">
+                        {[p.model, p.location || t('printerAccess.noLocation')].filter(Boolean).join(' · ')}
+                      </div>
+                    </div>
+                    <div className="flex flex-wrap items-center gap-1.5 flex-1 min-w-0">
+                      {reaching.map(({ group: g, reach }) =>
+                        reach === 'location' ? (
+                          <button
+                            key={g.id}
+                            type="button"
+                            onClick={() => setParams({ view: null, group: String(g.id), printer: null })}
+                            title={t('printerAccess.viaLocationTitle', { location: p.location })}
+                            className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-green/10 text-bambu-green border border-bambu-green/30"
+                          >
+                            <MapPin className="w-3 h-3" />
+                            {g.name}
+                          </button>
+                        ) : (
+                          <span
+                            key={g.id}
+                            className="flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full text-xs bg-bambu-green/20 text-bambu-green"
+                          >
+                            {g.name}
+                            <button
+                              type="button"
+                              onClick={() =>
+                                updateDraft(g, (d) => ({ ...d, printerIds: d.printerIds.filter((id) => id !== p.id) }))
+                              }
+                              aria-label={t('printerAccess.removeGroup', { group: g.name })}
+                              className="p-0.5 rounded-full hover:bg-bambu-green/30"
+                            >
+                              <X className="w-3 h-3" />
+                            </button>
+                          </span>
+                        )
+                      )}
+                      {reaching.length === 0 && limitedGroups.length > 0 && (
+                        <span className="text-xs text-bambu-gray">{t('printerAccess.noLimitedGroupReaches')}</span>
+                      )}
+                    </div>
+                    {addable.length > 0 && (
+                      <select
+                        value=""
+                        onChange={(e) => {
+                          const g = limitedGroups.find((x) => x.id === Number(e.target.value));
+                          if (g) updateDraft(g, (d) => ({ ...d, printerIds: [...d.printerIds, p.id] }));
+                        }}
+                        aria-label={t('printerAccess.addGroup')}
+                        className={`${inputClass} md:w-48 shrink-0`}
+                      >
+                        <option value="">{t('printerAccess.addGroup')}</option>
+                        {addable.map((g) => (
+                          <option key={g.id} value={g.id}>
+                            {g.name}
+                          </option>
+                        ))}
+                      </select>
+                    )}
+                  </div>
+                );
+              })}
+            </div>
+          </Card>
+        )}
+      </div>
+    );
+  };
+
+  return (
+    <div className="space-y-4 max-w-6xl" id="card-printer-access">
+      <p className="text-sm text-bambu-gray">{t('printerAccess.intro')}</p>
+
+      <div className="flex gap-1 p-1 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary w-fit">
+        {(['groups', 'printers'] as const).map((v) => (
+          <button
+            key={v}
+            type="button"
+            onClick={() => setParams({ view: v === 'groups' ? null : 'printers' })}
+            className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
+              view === v ? 'bg-bambu-green/20 text-bambu-green' : 'text-bambu-gray hover:text-white'
+            }`}
+          >
+            {v === 'groups' ? t('printerAccess.byGroup') : t('printerAccess.byPrinter')}
+          </button>
+        ))}
+      </div>
+
+      {view === 'groups' ? (
+        <div className="flex flex-col lg:flex-row gap-4 items-start">
+          {renderGroupList()}
+          {renderGroupEditor()}
+        </div>
+      ) : (
+        renderByPrinter()
+      )}
+
+      {isDirty && (
+        <div className="sticky bottom-0 z-20 flex flex-wrap items-center justify-between gap-3 px-4 py-3 rounded-xl bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-lg">
+          <span className="text-sm text-white">
+            {t('printerAccess.changedGroups', { names: dirtyGroups.map((g) => g.name).join(', ') })}
+          </span>
+          <div className="flex items-center gap-2">
+            <Button variant="secondary" onClick={() => setDrafts({})} disabled={saveMutation.isPending}>
+              {t('printerAccess.discard')}
+            </Button>
+            <Button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
+              {saveMutation.isPending ? (
+                <Loader2 className="w-4 h-4 animate-spin" />
+              ) : (
+                <Save className="w-4 h-4" />
+              )}
+              {t('common.save')}
+            </Button>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

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

@@ -1558,6 +1558,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: 'Wartet auf Freigabe',
       staged: 'Bereitgestellt',
       requiresPrevious: 'Erfordert vorherigen Erfolg',
       autoPowerOff: 'Automatisch ausschalten',
@@ -1695,6 +1696,7 @@ export default {
     permissions: {
       noStopPrint: 'Sie haben keine Berechtigung, Drucke zu stoppen',
       noStartPrint: 'Sie haben keine Berechtigung, Drucke zu starten',
+      awaitingReview: 'Wartet auf Freigabe: Jemand, der die Warteschlange verwaltet, startet diesen Auftrag',
       noEdit: 'Sie haben keine Berechtigung, dieses Warteschlangenelement zu bearbeiten',
       noCancel: 'Sie haben keine Berechtigung, dieses Warteschlangenelement abzubrechen',
       noRequeue: 'Sie haben keine Berechtigung, Elemente erneut einzureihen',
@@ -3590,9 +3592,73 @@ export default {
       permissionsSelected: '{{count}} ausgewählt',
       noResults: 'Keine Berechtigungen entsprechen Ihrer Suche',
       websocketHint: 'Erforderlich für Live-Aktualisierungen. Ohne diese Berechtigung greift die Oberfläche auf regelmäßiges Abrufen zurück.',
+      printerAccess: 'Druckerzugriff',
+      adminsSeeAllPrinters: 'Administratoren sehen immer alle Drucker.',
+      printerAccessAfterCreate: 'Sobald die Gruppe erstellt ist, wählen Sie ihre Drucker unter Einstellungen → Authentifizierung → Druckerzugriff.',
+      printerAccessLimited: 'Eingeschränkt: {{printers}} Drucker ausgewählt, {{locations}} Standorte',
+      printerAccessOpen: 'Nicht eingeschränkt: Mitglieder sehen alle Drucker.',
+      managePrinterAccess: 'Druckerzugriff verwalten',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Druckerzugriff',
+    intro: 'Legen Sie fest, welche Drucker die Mitglieder jeder Gruppe sehen und steuern dürfen. Eine nicht eingeschränkte Gruppe schränkt nichts ein: Ein Benutzer, der in keiner eingeschränkten Gruppe ist, sieht alle Drucker, und Administratoren sowieso.',
+    byGroup: 'Nach Gruppe',
+    byPrinter: 'Nach Drucker',
+    searchGroups: 'Gruppen durchsuchen',
+    groupFilter: {
+      all: 'Alle',
+      limited: 'Eingeschränkt',
+      open: 'Nicht eingeschränkt',
+    },
+    noGroupsMatch: 'Keine passenden Gruppen.',
+    alwaysAll: 'Immer alle Drucker',
+    notLimited: 'Alle Drucker',
+    reachShort: '{{count}} von {{total}} Druckern',
+    unsaved: 'Ungespeicherte Änderungen',
+    pickGroup: 'Wählen Sie eine Gruppe, um ihre Drucker festzulegen.',
+    memberCount: 'Mitglieder: {{count}}',
+    editGroup: 'Gruppe bearbeiten',
+    adminsSeeAll: 'Administratoren sehen immer alle Drucker.',
+    limit: 'Mitglieder auf die hier gewählten Drucker und Standorte beschränken',
+    limitHint: 'Aus: Diese Gruppe schränkt nicht ein, welche Drucker ihre Mitglieder sehen.',
+    reach: 'Mitglieder können {{count}} von {{total}} Druckern nutzen.',
+    noneGranted: 'Nichts ausgewählt: Mitglieder dieser Gruppe sehen keinen Drucker.',
+    noPrinters: 'Es wurden noch keine Drucker hinzugefügt.',
+    searchPrinters: 'Name, Modell, Seriennummer oder Standort suchen',
+    allLocations: 'Alle Standorte',
+    noLocation: 'Kein Standort',
+    allModels: 'Alle Modelle',
+    accessFilter: 'Zugriff',
+    accessAll: 'Mit und ohne Zugriff',
+    accessWith: 'Mit Zugriff',
+    accessWithout: 'Ohne Zugriff',
+    tickShown: 'Alle angezeigten auswählen',
+    untickShown: 'Alle angezeigten abwählen',
+    emptyLocation: 'derzeit ist hier kein Drucker',
+    wholeLocation: 'Ganzer Standort',
+    wholeLocationHint: 'Alle Drucker in {{location}}, auch später dort hinzugefügte',
+    noPrintersMatch: 'Keine passenden Drucker.',
+    viaLocation: 'über Standort',
+    alsoVisible: 'Neben den hier aufgeführten Gruppen ist jeder Drucker für Administratoren und für Benutzer sichtbar, die in keiner eingeschränkten Gruppe sind.',
+    openUsers: 'Benutzer in keiner eingeschränkten Gruppe: {{names}}',
+    noOpenUsers: 'Alle Benutzer außer Administratoren sind in einer eingeschränkten Gruppe.',
+    noLimitedGroups: 'Noch ist keine Gruppe eingeschränkt, daher sieht jeder Benutzer alle Drucker. Schränken Sie eine Gruppe unter „Nach Gruppe“ ein.',
+    noLimitedGroupReaches: 'Keine eingeschränkte Gruppe',
+    showingOne: 'Es wird ein Drucker angezeigt.',
+    showAll: 'Alle Drucker anzeigen',
+    viaLocationTitle: 'Über den Standort {{location}}. Ändern Sie dies unter „Nach Gruppe“.',
+    removeGroup: 'Zugriff für {{group}} entfernen',
+    addGroup: 'Zugriff gewähren für…',
+    changedGroups: 'Ungespeicherte Änderungen: {{names}}',
+    discard: 'Verwerfen',
+    saved: 'Druckerzugriff gespeichert',
+    whoHasAccess: 'Wer hat Zugriff',
+    moveWarning: 'Wenn Sie diesen Drucker an einen anderen Standort verschieben, ändert sich, wer ihn nutzen kann: {{groups}}.',
+  },
+
   // Users management
   users: {
     title: 'Benutzerverwaltung',
@@ -5376,6 +5442,7 @@ export default {
     invalidDateTime: 'Bitte ein gültiges Datum und eine gültige Uhrzeit eingeben',
     openCalendar: 'Kalender öffnen',
     requireManualStart: 'Manuellen Start erfordern',
+    awaitingReviewNote: 'Dein Druck wartet auf Freigabe: Er beginnt, sobald jemand, der die Warteschlange verwaltet, ihn startet.',
     requirePreviousSuccess: 'Nur starten, wenn der vorherige Druck erfolgreich war',
     autoOffAfter: 'Drucker nach Abschluss ausschalten',
     helpAsap: 'Der Druck wird oben in die Warteschlange eingefügt und startet, sobald ein geeigneter Drucker im Leerlauf ist.',

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

@@ -1576,6 +1576,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: 'Waiting for review',
       staged: 'Staged',
       requiresPrevious: 'Requires previous success',
       autoPowerOff: 'Auto power off',
@@ -1714,6 +1715,7 @@ export default {
     permissions: {
       noStopPrint: 'You do not have permission to stop prints',
       noStartPrint: 'You do not have permission to start prints',
+      awaitingReview: 'Waiting for review: someone who manages the queue starts this job',
       noEdit: 'You do not have permission to edit this queue item',
       noCancel: 'You do not have permission to cancel this queue item',
       noRequeue: 'You do not have permission to re-queue items',
@@ -3622,9 +3624,73 @@ export default {
       permissionsSelected: '{{count}} selected',
       noResults: 'No permissions match your search',
       websocketHint: 'Required for live updates. Without it, the interface falls back to periodic polling.',
+      printerAccess: 'Printer access',
+      adminsSeeAllPrinters: 'Administrators always see every printer.',
+      printerAccessAfterCreate: 'Once the group is created, choose its printers under Settings → Authentication → Printer access.',
+      printerAccessLimited: 'Limited: {{printers}} printers picked, {{locations}} locations',
+      printerAccessOpen: 'Not limited: members see every printer.',
+      managePrinterAccess: 'Manage printer access',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Printer access',
+    intro: 'Choose which printers each group\'s members may see and control. A group that isn\'t limited narrows nothing: a user who is in no limited group sees every printer, and administrators always do.',
+    byGroup: 'By group',
+    byPrinter: 'By printer',
+    searchGroups: 'Search groups',
+    groupFilter: {
+      all: 'All',
+      limited: 'Limited',
+      open: 'Not limited',
+    },
+    noGroupsMatch: 'No groups match.',
+    alwaysAll: 'Always every printer',
+    notLimited: 'Every printer',
+    reachShort: '{{count}} of {{total}} printers',
+    unsaved: 'Unsaved changes',
+    pickGroup: 'Pick a group to choose its printers.',
+    memberCount: 'Members: {{count}}',
+    editGroup: 'Edit group',
+    adminsSeeAll: 'Administrators always see every printer.',
+    limit: 'Limit members to the printers and locations chosen here',
+    limitHint: 'Off: this group doesn\'t narrow which printers its members see.',
+    reach: 'Members can use {{count}} of {{total}} printers.',
+    noneGranted: 'Nothing chosen: members of this group won\'t see any printer.',
+    noPrinters: 'No printers have been added yet.',
+    searchPrinters: 'Search name, model, serial or location',
+    allLocations: 'All locations',
+    noLocation: 'No location',
+    allModels: 'All models',
+    accessFilter: 'Access',
+    accessAll: 'With and without access',
+    accessWith: 'With access',
+    accessWithout: 'Without access',
+    tickShown: 'Tick all shown',
+    untickShown: 'Untick all shown',
+    emptyLocation: 'no printer is here at the moment',
+    wholeLocation: 'Whole location',
+    wholeLocationHint: 'Every printer in {{location}}, including printers added there later',
+    noPrintersMatch: 'No printers match.',
+    viaLocation: 'via location',
+    alsoVisible: 'Besides the groups listed here, every printer is visible to administrators and to users who are in no limited group.',
+    openUsers: 'Users in no limited group: {{names}}',
+    noOpenUsers: 'Every user apart from administrators is in a limited group.',
+    noLimitedGroups: 'No group is limited yet, so every user sees every printer. Limit a group under By group.',
+    noLimitedGroupReaches: 'No limited group',
+    showingOne: 'Showing one printer.',
+    showAll: 'Show all printers',
+    viaLocationTitle: 'Through location {{location}}. Change it under By group.',
+    removeGroup: 'Remove access for {{group}}',
+    addGroup: 'Give access to…',
+    changedGroups: 'Unsaved changes: {{names}}',
+    discard: 'Discard',
+    saved: 'Printer access saved',
+    whoHasAccess: 'Who has access',
+    moveWarning: 'Moving this printer to another location changes who can use it: {{groups}}.',
+  },
+
   // Users management
   users: {
     title: 'User Management',
@@ -5423,6 +5489,7 @@ export default {
     invalidDateTime: 'Please enter a valid date and time',
     openCalendar: 'Open calendar',
     requireManualStart: 'Require manual start',
+    awaitingReviewNote: 'Your print waits for review: it starts once someone who manages the queue starts it.',
     requirePreviousSuccess: 'Only start if previous print succeeded',
     autoOffAfter: 'Power off printer when done',
     helpAsap: 'Print will be added to the top of the queue and start as soon as an eligible printer is idle.',

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

@@ -1558,6 +1558,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: 'Esperando revisión',
       staged: 'Preparado',
       requiresPrevious: 'Requiere éxito previo',
       autoPowerOff: 'Apagado automático',
@@ -1695,6 +1696,7 @@ export default {
     permissions: {
       noStopPrint: 'No tiene permiso para detener impresiones',
       noStartPrint: 'No tiene permiso para iniciar impresiones',
+      awaitingReview: 'Esperando revisión: alguien que gestiona la cola inicia este trabajo',
       noEdit: 'No tiene permiso para editar este elemento de la cola',
       noCancel: 'No tiene permiso para cancelar este elemento de la cola',
       noRequeue: 'No tiene permiso para volver a encolar elementos',
@@ -3592,9 +3594,73 @@ export default {
       permissionsSelected: '{{count}} seleccionados',
       noResults: 'Ningún permiso coincide con su búsqueda',
       websocketHint: 'Necesario para las actualizaciones en vivo. Sin este permiso, la interfaz recurre al sondeo periódico.',
+      printerAccess: 'Acceso a impresoras',
+      adminsSeeAllPrinters: 'Los administradores siempre ven todas las impresoras.',
+      printerAccessAfterCreate: 'Una vez creado el grupo, elija sus impresoras en Ajustes → Autenticación → Acceso a impresoras.',
+      printerAccessLimited: 'Limitado: {{printers}} impresoras elegidas, {{locations}} ubicaciones',
+      printerAccessOpen: 'Sin límite: los miembros ven todas las impresoras.',
+      managePrinterAccess: 'Gestionar acceso a impresoras',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Acceso a impresoras',
+    intro: 'Elija qué impresoras pueden ver y controlar los miembros de cada grupo. Un grupo sin límite no restringe nada: un usuario que no está en ningún grupo limitado ve todas las impresoras, y los administradores siempre.',
+    byGroup: 'Por grupo',
+    byPrinter: 'Por impresora',
+    searchGroups: 'Buscar grupos',
+    groupFilter: {
+      all: 'Todos',
+      limited: 'Limitados',
+      open: 'Sin límite',
+    },
+    noGroupsMatch: 'Ningún grupo coincide.',
+    alwaysAll: 'Siempre todas las impresoras',
+    notLimited: 'Todas las impresoras',
+    reachShort: '{{count}} de {{total}} impresoras',
+    unsaved: 'Cambios sin guardar',
+    pickGroup: 'Elija un grupo para seleccionar sus impresoras.',
+    memberCount: 'Miembros: {{count}}',
+    editGroup: 'Editar grupo',
+    adminsSeeAll: 'Los administradores siempre ven todas las impresoras.',
+    limit: 'Limitar a los miembros a las impresoras y ubicaciones elegidas aquí',
+    limitHint: 'Desactivado: este grupo no restringe qué impresoras ven sus miembros.',
+    reach: 'Los miembros pueden usar {{count}} de {{total}} impresoras.',
+    noneGranted: 'Nada seleccionado: los miembros de este grupo no verán ninguna impresora.',
+    noPrinters: 'Aún no se ha añadido ninguna impresora.',
+    searchPrinters: 'Buscar por nombre, modelo, número de serie o ubicación',
+    allLocations: 'Todas las ubicaciones',
+    noLocation: 'Sin ubicación',
+    allModels: 'Todos los modelos',
+    accessFilter: 'Acceso',
+    accessAll: 'Con y sin acceso',
+    accessWith: 'Con acceso',
+    accessWithout: 'Sin acceso',
+    tickShown: 'Marcar todas las mostradas',
+    untickShown: 'Desmarcar todas las mostradas',
+    emptyLocation: 'ahora mismo no hay ninguna impresora aquí',
+    wholeLocation: 'Ubicación completa',
+    wholeLocationHint: 'Todas las impresoras de {{location}}, incluidas las que se añadan después',
+    noPrintersMatch: 'Ninguna impresora coincide.',
+    viaLocation: 'por ubicación',
+    alsoVisible: 'Además de los grupos aquí listados, todas las impresoras son visibles para los administradores y para los usuarios que no están en ningún grupo limitado.',
+    openUsers: 'Usuarios sin grupo limitado: {{names}}',
+    noOpenUsers: 'Todos los usuarios, salvo los administradores, están en un grupo limitado.',
+    noLimitedGroups: 'Todavía no hay ningún grupo limitado, así que todos los usuarios ven todas las impresoras. Limite un grupo en "Por grupo".',
+    noLimitedGroupReaches: 'Ningún grupo limitado',
+    showingOne: 'Mostrando una impresora.',
+    showAll: 'Mostrar todas las impresoras',
+    viaLocationTitle: 'A través de la ubicación {{location}}. Cámbielo en "Por grupo".',
+    removeGroup: 'Quitar acceso a {{group}}',
+    addGroup: 'Dar acceso a…',
+    changedGroups: 'Cambios sin guardar: {{names}}',
+    discard: 'Descartar',
+    saved: 'Acceso a impresoras guardado',
+    whoHasAccess: 'Quién tiene acceso',
+    moveWarning: 'Mover esta impresora a otra ubicación cambia quién puede usarla: {{groups}}.',
+  },
+
   // Users management
   users: {
     title: 'Gestión de usuarios',
@@ -5383,6 +5449,7 @@ export default {
     invalidDateTime: 'Introduzca una fecha y hora válidas',
     openCalendar: 'Abrir calendario',
     requireManualStart: 'Requerir inicio manual',
+    awaitingReviewNote: 'Tu impresión espera revisión: empieza cuando alguien que gestiona la cola la inicie.',
     requirePreviousSuccess: 'Iniciar solo si la impresión anterior se completó correctamente',
     autoOffAfter: 'Apagar la impresora al terminar',
     helpAsap: 'La impresión se añadirá al principio de la cola y comenzará en cuanto haya una impresora elegible inactiva.',

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

@@ -1558,6 +1558,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: 'En attente de validation',
       staged: 'Préparé',
       requiresPrevious: 'Nécessite succès précédent',
       autoPowerOff: 'Extinction auto',
@@ -1695,6 +1696,7 @@ export default {
     permissions: {
       noStopPrint: 'Pas d\'autorisation d\'arrêt',
       noStartPrint: 'Pas d\'autorisation de démarrage',
+      awaitingReview: 'En attente de validation : une personne qui gère la file lance ce travail',
       noEdit: 'Pas d\'autorisation de modification',
       noCancel: 'Pas d\'autorisation d\'annulation',
       noRequeue: 'Pas d\'autorisation de remise en file',
@@ -3578,9 +3580,73 @@ export default {
       permissionsSelected: '{{count}} sélectionnée(s)',
       noResults: 'Aucune permission ne correspond à votre recherche',
       websocketHint: "Requis pour les mises à jour en direct. Sans cette permission, l'interface bascule sur une actualisation périodique.",
+      printerAccess: 'Accès aux imprimantes',
+      adminsSeeAllPrinters: 'Les administrateurs voient toujours toutes les imprimantes.',
+      printerAccessAfterCreate: 'Une fois le groupe créé, choisissez ses imprimantes dans Paramètres → Authentification → Accès aux imprimantes.',
+      printerAccessLimited: 'Limité : {{printers}} imprimantes choisies, {{locations}} emplacements',
+      printerAccessOpen: 'Non limité : les membres voient toutes les imprimantes.',
+      managePrinterAccess: 'Gérer l\'accès aux imprimantes',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Accès aux imprimantes',
+    intro: 'Choisissez les imprimantes que les membres de chaque groupe peuvent voir et contrôler. Un groupe non limité ne restreint rien : un utilisateur qui n\'est dans aucun groupe limité voit toutes les imprimantes, tout comme les administrateurs.',
+    byGroup: 'Par groupe',
+    byPrinter: 'Par imprimante',
+    searchGroups: 'Rechercher des groupes',
+    groupFilter: {
+      all: 'Tous',
+      limited: 'Limités',
+      open: 'Non limités',
+    },
+    noGroupsMatch: 'Aucun groupe ne correspond.',
+    alwaysAll: 'Toujours toutes les imprimantes',
+    notLimited: 'Toutes les imprimantes',
+    reachShort: '{{count}} imprimantes sur {{total}}',
+    unsaved: 'Modifications non enregistrées',
+    pickGroup: 'Choisissez un groupe pour sélectionner ses imprimantes.',
+    memberCount: 'Membres : {{count}}',
+    editGroup: 'Modifier le groupe',
+    adminsSeeAll: 'Les administrateurs voient toujours toutes les imprimantes.',
+    limit: 'Limiter les membres aux imprimantes et emplacements choisis ici',
+    limitHint: 'Désactivé : ce groupe ne restreint pas les imprimantes que voient ses membres.',
+    reach: 'Les membres peuvent utiliser {{count}} imprimantes sur {{total}}.',
+    noneGranted: 'Rien de sélectionné : les membres de ce groupe ne verront aucune imprimante.',
+    noPrinters: 'Aucune imprimante n\'a encore été ajoutée.',
+    searchPrinters: 'Rechercher par nom, modèle, numéro de série ou emplacement',
+    allLocations: 'Tous les emplacements',
+    noLocation: 'Aucun emplacement',
+    allModels: 'Tous les modèles',
+    accessFilter: 'Accès',
+    accessAll: 'Avec et sans accès',
+    accessWith: 'Avec accès',
+    accessWithout: 'Sans accès',
+    tickShown: 'Cocher tout l\'affichage',
+    untickShown: 'Décocher tout l\'affichage',
+    emptyLocation: 'aucune imprimante ici pour le moment',
+    wholeLocation: 'Emplacement entier',
+    wholeLocationHint: 'Toutes les imprimantes de {{location}}, y compris celles ajoutées plus tard',
+    noPrintersMatch: 'Aucune imprimante ne correspond.',
+    viaLocation: 'via l\'emplacement',
+    alsoVisible: 'En plus des groupes listés ici, chaque imprimante est visible par les administrateurs et par les utilisateurs qui ne sont dans aucun groupe limité.',
+    openUsers: 'Utilisateurs dans aucun groupe limité : {{names}}',
+    noOpenUsers: 'Tous les utilisateurs, hormis les administrateurs, sont dans un groupe limité.',
+    noLimitedGroups: 'Aucun groupe n\'est encore limité, donc chaque utilisateur voit toutes les imprimantes. Limitez un groupe dans « Par groupe ».',
+    noLimitedGroupReaches: 'Aucun groupe limité',
+    showingOne: 'Une seule imprimante affichée.',
+    showAll: 'Afficher toutes les imprimantes',
+    viaLocationTitle: 'Via l\'emplacement {{location}}. Modifiez-le dans « Par groupe ».',
+    removeGroup: 'Retirer l\'accès pour {{group}}',
+    addGroup: 'Donner l\'accès à…',
+    changedGroups: 'Modifications non enregistrées : {{names}}',
+    discard: 'Annuler',
+    saved: 'Accès aux imprimantes enregistré',
+    whoHasAccess: 'Qui a accès',
+    moveWarning: 'Déplacer cette imprimante vers un autre emplacement change qui peut l\'utiliser : {{groups}}.',
+  },
+
   // Users management
   users: {
     title: 'Gestion des Utilisateurs',
@@ -5363,6 +5429,7 @@ export default {
     invalidDateTime: 'Veuillez saisir une date et une heure valides',
     openCalendar: 'Ouvrir calendrier',
     requireManualStart: 'Démarrage manuel requis',
+    awaitingReviewNote: 'Votre impression attend une validation : elle démarre quand une personne qui gère la file la lance.',
     requirePreviousSuccess: 'Démarrer seulement si l\'impression précédente a réussi',
     autoOffAfter: 'Éteindre l\'imprimante à la fin',
     helpAsap: 'L\'impression sera ajoutée en haut de la file et démarrera dès qu\'une imprimante éligible sera inactive.',

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

@@ -1558,6 +1558,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: 'In attesa di revisione',
       staged: 'In staging',
       requiresPrevious: 'Richiede successo precedente',
       autoPowerOff: 'Spegnimento automatico',
@@ -1695,6 +1696,7 @@ export default {
     permissions: {
       noStopPrint: 'Non hai il permesso di fermare stampe',
       noStartPrint: 'Non hai il permesso di avviare stampe',
+      awaitingReview: 'In attesa di revisione: qualcuno che gestisce la coda avvia questo lavoro',
       noEdit: 'Non hai il permesso di modificare questo elemento coda',
       noCancel: 'Non hai il permesso di annullare questo elemento coda',
       noRequeue: 'Non hai il permesso di rimettere in coda elementi',
@@ -3577,9 +3579,73 @@ export default {
       permissionsSelected: '{{count}} selezionati',
       noResults: 'Nessun permesso corrisponde alla ricerca',
       websocketHint: "Necessario per gli aggiornamenti in tempo reale. Senza questo permesso, l'interfaccia ripiega sul polling periodico.",
+      printerAccess: 'Accesso alle stampanti',
+      adminsSeeAllPrinters: 'Gli amministratori vedono sempre tutte le stampanti.',
+      printerAccessAfterCreate: 'Una volta creato il gruppo, scegli le sue stampanti in Impostazioni → Utenti → Accesso alle stampanti.',
+      printerAccessLimited: 'Limitato: {{printers}} stampanti scelte, {{locations}} posizioni',
+      printerAccessOpen: 'Non limitato: i membri vedono tutte le stampanti.',
+      managePrinterAccess: 'Gestisci accesso alle stampanti',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Accesso alle stampanti',
+    intro: 'Scegli quali stampanti i membri di ciascun gruppo possono vedere e controllare. Un gruppo non limitato non restringe nulla: un utente che non fa parte di alcun gruppo limitato vede tutte le stampanti, e gli amministratori sempre.',
+    byGroup: 'Per gruppo',
+    byPrinter: 'Per stampante',
+    searchGroups: 'Cerca gruppi',
+    groupFilter: {
+      all: 'Tutti',
+      limited: 'Limitati',
+      open: 'Non limitati',
+    },
+    noGroupsMatch: 'Nessun gruppo corrispondente.',
+    alwaysAll: 'Sempre tutte le stampanti',
+    notLimited: 'Tutte le stampanti',
+    reachShort: '{{count}} di {{total}} stampanti',
+    unsaved: 'Modifiche non salvate',
+    pickGroup: 'Scegli un gruppo per selezionarne le stampanti.',
+    memberCount: 'Membri: {{count}}',
+    editGroup: 'Modifica gruppo',
+    adminsSeeAll: 'Gli amministratori vedono sempre tutte le stampanti.',
+    limit: 'Limita i membri alle stampanti e posizioni scelte qui',
+    limitHint: 'Disattivato: questo gruppo non restringe le stampanti visibili ai suoi membri.',
+    reach: 'I membri possono usare {{count}} di {{total}} stampanti.',
+    noneGranted: 'Nulla selezionato: i membri di questo gruppo non vedranno alcuna stampante.',
+    noPrinters: 'Non è ancora stata aggiunta alcuna stampante.',
+    searchPrinters: 'Cerca nome, modello, numero di serie o posizione',
+    allLocations: 'Tutte le posizioni',
+    noLocation: 'Nessuna posizione',
+    allModels: 'Tutti i modelli',
+    accessFilter: 'Accesso',
+    accessAll: 'Con e senza accesso',
+    accessWith: 'Con accesso',
+    accessWithout: 'Senza accesso',
+    tickShown: 'Seleziona tutte quelle mostrate',
+    untickShown: 'Deseleziona tutte quelle mostrate',
+    emptyLocation: 'al momento qui non c\'è alcuna stampante',
+    wholeLocation: 'Intera posizione',
+    wholeLocationHint: 'Tutte le stampanti in {{location}}, incluse quelle aggiunte in seguito',
+    noPrintersMatch: 'Nessuna stampante corrispondente.',
+    viaLocation: 'tramite posizione',
+    alsoVisible: 'Oltre ai gruppi elencati qui, ogni stampante è visibile agli amministratori e agli utenti che non fanno parte di alcun gruppo limitato.',
+    openUsers: 'Utenti in nessun gruppo limitato: {{names}}',
+    noOpenUsers: 'Tutti gli utenti, tranne gli amministratori, fanno parte di un gruppo limitato.',
+    noLimitedGroups: 'Nessun gruppo è ancora limitato, quindi ogni utente vede tutte le stampanti. Limita un gruppo in "Per gruppo".',
+    noLimitedGroupReaches: 'Nessun gruppo limitato',
+    showingOne: 'Viene mostrata una sola stampante.',
+    showAll: 'Mostra tutte le stampanti',
+    viaLocationTitle: 'Tramite la posizione {{location}}. Modificala in "Per gruppo".',
+    removeGroup: 'Rimuovi l\'accesso per {{group}}',
+    addGroup: 'Concedi l\'accesso a…',
+    changedGroups: 'Modifiche non salvate: {{names}}',
+    discard: 'Annulla modifiche',
+    saved: 'Accesso alle stampanti salvato',
+    whoHasAccess: 'Chi ha accesso',
+    moveWarning: 'Spostare questa stampante in un\'altra posizione cambia chi può usarla: {{groups}}.',
+  },
+
   // Users management
   users: {
     title: 'Gestione utenti',
@@ -5362,6 +5428,7 @@ export default {
     invalidDateTime: 'Inserisci data e ora valide',
     openCalendar: 'Apri calendario',
     requireManualStart: 'Richiedi avvio manuale',
+    awaitingReviewNote: 'La tua stampa attende la revisione: parte quando qualcuno che gestisce la coda la avvia.',
     requirePreviousSuccess: 'Avvia solo se la stampa precedente è riuscita',
     autoOffAfter: 'Spegni la stampante al termine',
     helpAsap: 'La stampa verrà aggiunta in cima alla coda e partirà appena una stampante idonea è inattiva.',

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

@@ -1557,6 +1557,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: 'レビュー待ち',
       staged: 'ステージ済み',
       requiresPrevious: '前の成功が必要',
       autoPowerOff: '自動電源オフ',
@@ -1694,6 +1695,7 @@ export default {
     permissions: {
       noStopPrint: '印刷を停止する権限がありません',
       noStartPrint: '印刷を開始する権限がありません',
+      awaitingReview: 'レビュー待ち:キューを管理する人がこのジョブを開始します',
       noEdit: 'このキューアイテムを編集する権限がありません',
       noCancel: 'このキューアイテムをキャンセルする権限がありません',
       noRequeue: 'アイテムを再キューする権限がありません',
@@ -3590,9 +3592,73 @@ export default {
       permissionsSelected: '{{count}}件選択',
       noResults: '検索に一致する権限がありません',
       websocketHint: 'ライブ更新に必要です。この権限がないと、インターフェースは定期的なポーリングに切り替わります。',
+      printerAccess: 'プリンターへのアクセス',
+      adminsSeeAllPrinters: '管理者には常にすべてのプリンターが表示されます。',
+      printerAccessAfterCreate: 'グループを作成したら、設定 → 認証 → プリンターへのアクセス でプリンターを選択してください。',
+      printerAccessLimited: '制限あり: {{printers}} 台のプリンターを選択、{{locations}} 件のロケーション',
+      printerAccessOpen: '制限なし: メンバーはすべてのプリンターを表示できます。',
+      managePrinterAccess: 'プリンターへのアクセスを管理',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'プリンターへのアクセス',
+    intro: '各グループのメンバーが表示・操作できるプリンターを選択します。制限のないグループは何も絞り込みません。制限付きグループに属していないユーザーはすべてのプリンターを表示でき、管理者も常にすべてを表示できます。',
+    byGroup: 'グループ別',
+    byPrinter: 'プリンター別',
+    searchGroups: 'グループを検索',
+    groupFilter: {
+      all: 'すべて',
+      limited: '制限あり',
+      open: '制限なし',
+    },
+    noGroupsMatch: '一致するグループはありません。',
+    alwaysAll: '常にすべてのプリンター',
+    notLimited: 'すべてのプリンター',
+    reachShort: '{{total}} 台中 {{count}} 台',
+    unsaved: '未保存の変更',
+    pickGroup: 'グループを選ぶとプリンターを選択できます。',
+    memberCount: 'メンバー: {{count}}',
+    editGroup: 'グループを編集',
+    adminsSeeAll: '管理者には常にすべてのプリンターが表示されます。',
+    limit: 'メンバーをここで選択したプリンターとロケーションに制限する',
+    limitHint: 'オフ: このグループはメンバーに表示されるプリンターを絞り込みません。',
+    reach: 'メンバーは {{total}} 台中 {{count}} 台のプリンターを使用できます。',
+    noneGranted: '何も選択されていません。このグループのメンバーにはプリンターが表示されません。',
+    noPrinters: 'プリンターはまだ追加されていません。',
+    searchPrinters: '名前、モデル、シリアル番号、ロケーションで検索',
+    allLocations: 'すべてのロケーション',
+    noLocation: 'ロケーションなし',
+    allModels: 'すべてのモデル',
+    accessFilter: 'アクセス',
+    accessAll: 'アクセスの有無を問わない',
+    accessWith: 'アクセスあり',
+    accessWithout: 'アクセスなし',
+    tickShown: '表示中をすべてチェック',
+    untickShown: '表示中のチェックをすべて外す',
+    emptyLocation: '現在このロケーションにプリンターはありません',
+    wholeLocation: 'ロケーション全体',
+    wholeLocationHint: '{{location}} のすべてのプリンター (後から追加されたものも含む)',
+    noPrintersMatch: '一致するプリンターはありません。',
+    viaLocation: 'ロケーション経由',
+    alsoVisible: 'ここに表示されているグループのほか、すべてのプリンターは管理者と、制限付きグループに属していないユーザーにも表示されます。',
+    openUsers: '制限付きグループに属していないユーザー: {{names}}',
+    noOpenUsers: '管理者以外のすべてのユーザーが制限付きグループに属しています。',
+    noLimitedGroups: '制限付きのグループはまだないため、すべてのユーザーにすべてのプリンターが表示されます。「グループ別」でグループを制限してください。',
+    noLimitedGroupReaches: '制限付きグループなし',
+    showingOne: 'プリンターを 1 台表示しています。',
+    showAll: 'すべてのプリンターを表示',
+    viaLocationTitle: 'ロケーション {{location}} 経由です。「グループ別」で変更してください。',
+    removeGroup: '{{group}} のアクセスを削除',
+    addGroup: 'アクセスを付与…',
+    changedGroups: '未保存の変更: {{names}}',
+    discard: '破棄',
+    saved: 'プリンターへのアクセスを保存しました',
+    whoHasAccess: 'アクセスできるユーザー',
+    moveWarning: 'このプリンターを別のロケーションに移動すると、使用できるグループが変わります: {{groups}}。',
+  },
+
   // Users management
   users: {
     title: 'ユーザー管理',
@@ -5376,6 +5442,7 @@ export default {
     invalidDateTime: '有効な日時を入力してください',
     openCalendar: 'カレンダーを開く',
     requireManualStart: '手動開始を要求',
+    awaitingReviewNote: 'この印刷はレビュー待ちです。キューを管理する人が開始すると印刷が始まります。',
     requirePreviousSuccess: '前の印刷が成功した場合のみ開始',
     autoOffAfter: '完了後にプリンターの電源を切る',
     helpAsap: '印刷はキューの先頭に追加され、対象プリンターがアイドルになるとすぐに開始します。',

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

@@ -1485,6 +1485,7 @@ export default {
       descendingNewest: '내림차순 (최신 것 먼저)'
     },
     badges: {
+      awaitingReview: '검토 대기 중',
       staged: '준비됨',
       requiresPrevious: '이전 성공 필요',
       autoPowerOff: '자동 전원 끄기',
@@ -1614,6 +1615,7 @@ export default {
     permissions: {
       noStopPrint: '인쇄를 정지할 권한이 없습니다',
       noStartPrint: '인쇄를 시작할 권한이 없습니다',
+      awaitingReview: '검토 대기 중: 대기열을 관리하는 사람이 이 작업을 시작합니다',
       noEdit: '이 대기열 항목을 편집할 권한이 없습니다',
       noCancel: '이 대기열 항목을 취소할 권한이 없습니다',
       noRequeue: '항목을 재대기할 권한이 없습니다',
@@ -3417,9 +3419,74 @@ export default {
       clearAll: '모두 해제',
       permissionsSelected: '{{count}}개 선택됨',
       noResults: '검색과 일치하는 권한이 없습니다',
-      websocketHint: '실시간 업데이트에 필요합니다. 이 권한이 없으면 인터페이스는 주기적 폴링으로 대체됩니다.'
+      websocketHint: '실시간 업데이트에 필요합니다. 이 권한이 없으면 인터페이스는 주기적 폴링으로 대체됩니다.',
+      printerAccess: '프린터 접근',
+      adminsSeeAllPrinters: '관리자는 항상 모든 프린터를 볼 수 있습니다.',
+      printerAccessAfterCreate: '그룹을 만든 후 설정 → 인증 → 프린터 접근에서 프린터를 선택하세요.',
+      printerAccessLimited: '제한됨: 프린터 {{printers}}대 선택, 위치 {{locations}}개',
+      printerAccessOpen: '제한 없음: 구성원은 모든 프린터를 볼 수 있습니다.',
+      managePrinterAccess: '프린터 접근 관리',
     }
   },
+
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: '프린터 접근',
+    intro: '각 그룹의 구성원이 보고 제어할 수 있는 프린터를 선택하세요. 제한되지 않은 그룹은 아무것도 좁히지 않습니다. 제한된 그룹에 속하지 않은 사용자는 모든 프린터를 볼 수 있으며, 관리자도 항상 그렇습니다.',
+    byGroup: '그룹별',
+    byPrinter: '프린터별',
+    searchGroups: '그룹 검색',
+    groupFilter: {
+      all: '전체',
+      limited: '제한됨',
+      open: '제한 없음',
+    },
+    noGroupsMatch: '일치하는 그룹이 없습니다.',
+    alwaysAll: '항상 모든 프린터',
+    notLimited: '모든 프린터',
+    reachShort: '프린터 {{total}}대 중 {{count}}대',
+    unsaved: '저장되지 않은 변경 사항',
+    pickGroup: '프린터를 선택하려면 그룹을 고르세요.',
+    memberCount: '구성원: {{count}}',
+    editGroup: '그룹 편집',
+    adminsSeeAll: '관리자는 항상 모든 프린터를 볼 수 있습니다.',
+    limit: '구성원을 여기서 선택한 프린터와 위치로 제한',
+    limitHint: '꺼짐: 이 그룹은 구성원이 볼 수 있는 프린터를 제한하지 않습니다.',
+    reach: '구성원은 프린터 {{total}}대 중 {{count}}대를 사용할 수 있습니다.',
+    noneGranted: '선택된 항목이 없습니다. 이 그룹의 구성원에게는 프린터가 표시되지 않습니다.',
+    noPrinters: '아직 추가된 프린터가 없습니다.',
+    searchPrinters: '이름, 모델, 일련번호 또는 위치 검색',
+    allLocations: '모든 위치',
+    noLocation: '위치 없음',
+    allModels: '모든 모델',
+    accessFilter: '접근',
+    accessAll: '접근 가능 및 불가 모두',
+    accessWith: '접근 가능',
+    accessWithout: '접근 불가',
+    tickShown: '표시된 항목 모두 선택',
+    untickShown: '표시된 항목 모두 선택 해제',
+    emptyLocation: '현재 이 위치에 프린터가 없습니다',
+    wholeLocation: '위치 전체',
+    wholeLocationHint: '{{location}}의 모든 프린터 (나중에 추가되는 프린터 포함)',
+    noPrintersMatch: '일치하는 프린터가 없습니다.',
+    viaLocation: '위치를 통해',
+    alsoVisible: '여기에 나열된 그룹 외에도 모든 프린터는 관리자와 제한된 그룹에 속하지 않은 사용자에게 표시됩니다.',
+    openUsers: '제한된 그룹에 속하지 않은 사용자: {{names}}',
+    noOpenUsers: '관리자를 제외한 모든 사용자가 제한된 그룹에 속해 있습니다.',
+    noLimitedGroups: '아직 제한된 그룹이 없으므로 모든 사용자가 모든 프린터를 볼 수 있습니다. "그룹별"에서 그룹을 제한하세요.',
+    noLimitedGroupReaches: '제한된 그룹 없음',
+    showingOne: '프린터 1대를 표시하고 있습니다.',
+    showAll: '모든 프린터 표시',
+    viaLocationTitle: '위치 {{location}}을(를) 통해 접근합니다. "그룹별"에서 변경하세요.',
+    removeGroup: '{{group}}의 접근 제거',
+    addGroup: '접근 권한 부여…',
+    changedGroups: '저장되지 않은 변경 사항: {{names}}',
+    discard: '취소',
+    saved: '프린터 접근이 저장되었습니다',
+    whoHasAccess: '접근 가능한 대상',
+    moveWarning: '이 프린터를 다른 위치로 옮기면 사용할 수 있는 그룹이 바뀝니다: {{groups}}.',
+  },
+
   users: {
     title: '사용자 관리',
     subtitle: 'Bambuddy 인스턴스에 대한 사용자 및 접근 권한 관리',
@@ -5144,6 +5211,7 @@ export default {
     invalidDateTime: '유효한 날짜와 시간을 입력하세요',
     openCalendar: '달력 열기',
     requireManualStart: '수동 시작 필요',
+    awaitingReviewNote: '이 출력은 검토를 기다립니다. 대기열을 관리하는 사람이 시작하면 출력이 시작됩니다.',
     requirePreviousSuccess: '이전 인쇄가 성공한 경우에만 시작',
     autoOffAfter: '완료 후 프린터 전원 끄기',
     helpAsap: '인쇄가 대기열 맨 앞에 추가되고 적합한 프린터가 유휴 상태가 되는 즉시 시작됩니다.',

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

@@ -1574,6 +1574,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: 'Wacht op beoordeling',
       staged: 'Gereed voor handmatige start',
       requiresPrevious: 'Vereist dat vorige taak slaagt',
       autoPowerOff: 'Automatisch uitschakelen',
@@ -1712,6 +1713,7 @@ export default {
     permissions: {
       noStopPrint: 'Je hebt geen toestemming om afdrukken te stoppen',
       noStartPrint: 'Je hebt geen toestemming om afdrukken te starten',
+      awaitingReview: 'Wacht op beoordeling: iemand die de wachtrij beheert start deze taak',
       noEdit: 'Je hebt geen toestemming om dit wachtrij-item te bewerken',
       noCancel: 'Je hebt geen toestemming om dit wachtrij-item te annuleren',
       noRequeue: 'Je hebt geen toestemming om items opnieuw in de wachtrij te plaatsen',
@@ -3620,9 +3622,73 @@ export default {
       permissionsSelected: '{{count}} geselecteerd',
       noResults: 'Geen machtigingen komen overeen met je zoekopdracht',
       websocketHint: 'Vereist voor live-updates. Zonder dit valt de interface terug op periodieke polling.',
+      printerAccess: 'Printertoegang',
+      adminsSeeAllPrinters: 'Beheerders zien altijd alle printers.',
+      printerAccessAfterCreate: 'Kies na het aanmaken van de groep de printers onder Instellingen → Authenticatie → Printertoegang.',
+      printerAccessLimited: 'Beperkt: {{printers}} printers gekozen, {{locations}} locaties',
+      printerAccessOpen: 'Niet beperkt: leden zien alle printers.',
+      managePrinterAccess: 'Printertoegang beheren',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Printertoegang',
+    intro: 'Kies welke printers de leden van elke groep mogen zien en bedienen. Een niet-beperkte groep beperkt niets: een gebruiker die in geen enkele beperkte groep zit, ziet alle printers, en beheerders altijd.',
+    byGroup: 'Per groep',
+    byPrinter: 'Per printer',
+    searchGroups: 'Groepen zoeken',
+    groupFilter: {
+      all: 'Alle',
+      limited: 'Beperkt',
+      open: 'Niet beperkt',
+    },
+    noGroupsMatch: 'Geen groepen gevonden.',
+    alwaysAll: 'Altijd alle printers',
+    notLimited: 'Alle printers',
+    reachShort: '{{count}} van {{total}} printers',
+    unsaved: 'Niet-opgeslagen wijzigingen',
+    pickGroup: 'Kies een groep om de printers ervan te selecteren.',
+    memberCount: 'Leden: {{count}}',
+    editGroup: 'Groep bewerken',
+    adminsSeeAll: 'Beheerders zien altijd alle printers.',
+    limit: 'Leden beperken tot de hier gekozen printers en locaties',
+    limitHint: 'Uit: deze groep beperkt niet welke printers de leden zien.',
+    reach: 'Leden kunnen {{count}} van {{total}} printers gebruiken.',
+    noneGranted: 'Niets gekozen: leden van deze groep zien geen enkele printer.',
+    noPrinters: 'Er zijn nog geen printers toegevoegd.',
+    searchPrinters: 'Zoeken op naam, model, serienummer of locatie',
+    allLocations: 'Alle locaties',
+    noLocation: 'Geen locatie',
+    allModels: 'Alle modellen',
+    accessFilter: 'Toegang',
+    accessAll: 'Met en zonder toegang',
+    accessWith: 'Met toegang',
+    accessWithout: 'Zonder toegang',
+    tickShown: 'Alle getoonde aanvinken',
+    untickShown: 'Alle getoonde uitvinken',
+    emptyLocation: 'er staat hier momenteel geen printer',
+    wholeLocation: 'Hele locatie',
+    wholeLocationHint: 'Alle printers in {{location}}, ook printers die er later bijkomen',
+    noPrintersMatch: 'Geen printers gevonden.',
+    viaLocation: 'via locatie',
+    alsoVisible: 'Naast de hier vermelde groepen is elke printer zichtbaar voor beheerders en voor gebruikers die in geen enkele beperkte groep zitten.',
+    openUsers: 'Gebruikers zonder beperkte groep: {{names}}',
+    noOpenUsers: 'Elke gebruiker behalve beheerders zit in een beperkte groep.',
+    noLimitedGroups: 'Er is nog geen groep beperkt, dus elke gebruiker ziet alle printers. Beperk een groep onder "Per groep".',
+    noLimitedGroupReaches: 'Geen beperkte groep',
+    showingOne: 'Er wordt één printer getoond.',
+    showAll: 'Alle printers tonen',
+    viaLocationTitle: 'Via locatie {{location}}. Wijzig dit onder "Per groep".',
+    removeGroup: 'Toegang voor {{group}} verwijderen',
+    addGroup: 'Toegang geven aan…',
+    changedGroups: 'Niet-opgeslagen wijzigingen: {{names}}',
+    discard: 'Verwerpen',
+    saved: 'Printertoegang opgeslagen',
+    whoHasAccess: 'Wie heeft toegang',
+    moveWarning: 'Als je deze printer naar een andere locatie verplaatst, verandert wie hem kan gebruiken: {{groups}}.',
+  },
+
   // Users management
   users: {
     title: 'Gebruikersbeheer',
@@ -5421,6 +5487,7 @@ export default {
     invalidDateTime: 'Voer een geldige datum en tijd in',
     openCalendar: 'Kalender openen',
     requireManualStart: 'Handmatige start vereisen',
+    awaitingReviewNote: 'Je afdruk wacht op beoordeling: hij begint zodra iemand die de wachtrij beheert hem start.',
     requirePreviousSuccess: 'Alleen starten als vorige afdruk is geslaagd',
     autoOffAfter: 'Printer uitschakelen wanneer klaar',
     helpAsap: 'Afdruk wordt bovenaan de wachtrij geplaatst en start zodra een geschikte printer inactief is.',

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

@@ -1558,6 +1558,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: 'Aguardando revisão',
       staged: 'Preparado (início manual)',
       requiresPrevious: 'Requer sucesso anterior',
       autoPowerOff: 'Desligamento automático',
@@ -1695,6 +1696,7 @@ export default {
     permissions: {
       noStopPrint: 'Você não tem permissão para parar impressões',
       noStartPrint: 'Você não tem permissão para iniciar impressões',
+      awaitingReview: 'Aguardando revisão: alguém que gerencia a fila inicia este trabalho',
       noEdit: 'Você não tem permissão para editar este item da fila',
       noCancel: 'Você não tem permissão para cancelar este item da fila',
       noRequeue: 'Você não tem permissão para reenfileirar itens',
@@ -3577,9 +3579,73 @@ export default {
       permissionsSelected: '{{count}} selecionada(s)',
       noResults: 'Nenhuma permissão corresponde à sua pesquisa',
       websocketHint: 'Necessário para atualizações em tempo real. Sem esta permissão, a interface recorre à sondagem periódica.',
+      printerAccess: 'Acesso às impressoras',
+      adminsSeeAllPrinters: 'Administradores sempre veem todas as impressoras.',
+      printerAccessAfterCreate: 'Depois de criar o grupo, escolha as impressoras dele em Configurações → Autenticação → Acesso às impressoras.',
+      printerAccessLimited: 'Limitado: {{printers}} impressoras escolhidas, {{locations}} locais',
+      printerAccessOpen: 'Sem limite: os membros veem todas as impressoras.',
+      managePrinterAccess: 'Gerenciar acesso às impressoras',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Acesso às impressoras',
+    intro: 'Escolha quais impressoras os membros de cada grupo podem ver e controlar. Um grupo sem limite não restringe nada: um usuário que não está em nenhum grupo limitado vê todas as impressoras, e os administradores sempre veem.',
+    byGroup: 'Por grupo',
+    byPrinter: 'Por impressora',
+    searchGroups: 'Buscar grupos',
+    groupFilter: {
+      all: 'Todos',
+      limited: 'Limitados',
+      open: 'Sem limite',
+    },
+    noGroupsMatch: 'Nenhum grupo encontrado.',
+    alwaysAll: 'Sempre todas as impressoras',
+    notLimited: 'Todas as impressoras',
+    reachShort: '{{count}} de {{total}} impressoras',
+    unsaved: 'Alterações não salvas',
+    pickGroup: 'Escolha um grupo para selecionar as impressoras dele.',
+    memberCount: 'Membros: {{count}}',
+    editGroup: 'Editar grupo',
+    adminsSeeAll: 'Administradores sempre veem todas as impressoras.',
+    limit: 'Limitar os membros às impressoras e locais escolhidos aqui',
+    limitHint: 'Desativado: este grupo não restringe quais impressoras seus membros veem.',
+    reach: 'Os membros podem usar {{count}} de {{total}} impressoras.',
+    noneGranted: 'Nada selecionado: os membros deste grupo não verão nenhuma impressora.',
+    noPrinters: 'Nenhuma impressora foi adicionada ainda.',
+    searchPrinters: 'Buscar por nome, modelo, número de série ou local',
+    allLocations: 'Todos os locais',
+    noLocation: 'Sem local',
+    allModels: 'Todos os modelos',
+    accessFilter: 'Acesso',
+    accessAll: 'Com e sem acesso',
+    accessWith: 'Com acesso',
+    accessWithout: 'Sem acesso',
+    tickShown: 'Marcar todas as exibidas',
+    untickShown: 'Desmarcar todas as exibidas',
+    emptyLocation: 'nenhuma impressora aqui no momento',
+    wholeLocation: 'Local inteiro',
+    wholeLocationHint: 'Todas as impressoras em {{location}}, incluindo as adicionadas depois',
+    noPrintersMatch: 'Nenhuma impressora encontrada.',
+    viaLocation: 'pelo local',
+    alsoVisible: 'Além dos grupos listados aqui, todas as impressoras são visíveis para administradores e para usuários que não estão em nenhum grupo limitado.',
+    openUsers: 'Usuários sem grupo limitado: {{names}}',
+    noOpenUsers: 'Todos os usuários, exceto administradores, estão em um grupo limitado.',
+    noLimitedGroups: 'Nenhum grupo está limitado ainda, então todos os usuários veem todas as impressoras. Limite um grupo em "Por grupo".',
+    noLimitedGroupReaches: 'Nenhum grupo limitado',
+    showingOne: 'Exibindo uma impressora.',
+    showAll: 'Mostrar todas as impressoras',
+    viaLocationTitle: 'Pelo local {{location}}. Altere em "Por grupo".',
+    removeGroup: 'Remover acesso de {{group}}',
+    addGroup: 'Dar acesso a…',
+    changedGroups: 'Alterações não salvas: {{names}}',
+    discard: 'Descartar',
+    saved: 'Acesso às impressoras salvo',
+    whoHasAccess: 'Quem tem acesso',
+    moveWarning: 'Mover esta impressora para outro local muda quem pode usá-la: {{groups}}.',
+  },
+
   // Users management
   users: {
     title: 'Gerenciamento de Usuários',
@@ -5362,6 +5428,7 @@ export default {
     invalidDateTime: 'Digite uma data e hora válidas',
     openCalendar: 'Abrir calendário',
     requireManualStart: 'Exigir início manual',
+    awaitingReviewNote: 'Sua impressão aguarda revisão: ela começa quando alguém que gerencia a fila a iniciar.',
     requirePreviousSuccess: 'Iniciar somente se a impressão anterior foi concluída com sucesso',
     autoOffAfter: 'Desligar impressora ao finalizar',
     helpAsap: 'A impressão será adicionada ao topo da fila e começará assim que uma impressora elegível estiver ociosa.',

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

@@ -1499,6 +1499,7 @@ export default {
       descendingNewest: "По убыванию (сначала новые)",
     },
     badges: {
+      awaitingReview: 'Ожидает проверки',
       staged: "Подготовлено",
       requiresPrevious: "Требуется успешное предыдущее задание",
       autoPowerOff: "Автоотключение питания",
@@ -1628,6 +1629,7 @@ export default {
     permissions: {
       noStopPrint: "У вас нет разрешения останавливать печать",
       noStartPrint: "У вас нет разрешения запускать печать",
+      awaitingReview: "Ожидает проверки: это задание запускает тот, кто управляет очередью",
       noEdit: "У вас нет разрешения изменять это задание",
       noCancel: "У вас нет разрешения отменять это задание",
       noRequeue: "У вас нет разрешения возвращать задания в очередь",
@@ -3410,8 +3412,73 @@ export default {
       permissionsSelected: "Выбрано: {{count}}",
       noResults: "Нет разрешений, соответствующих запросу",
       websocketHint: "Необходимо для обновлений в реальном времени. Без этого интерфейс будет периодически опрашивать сервер.",
+      printerAccess: 'Доступ к принтерам',
+      adminsSeeAllPrinters: 'Администраторы всегда видят все принтеры.',
+      printerAccessAfterCreate: 'После создания группы выберите её принтеры в разделе Настройки → Аутентификация → Доступ к принтерам.',
+      printerAccessLimited: 'Ограничена: выбрано принтеров — {{printers}}, расположений — {{locations}}',
+      printerAccessOpen: 'Без ограничений: участники видят все принтеры.',
+      managePrinterAccess: 'Управление доступом к принтерам',
     },
   },
+
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Доступ к принтерам',
+    intro: 'Выберите, какие принтеры могут видеть и использовать участники каждой группы. Группа без ограничений ничего не сужает: пользователь, не состоящий ни в одной ограниченной группе, видит все принтеры, как и администраторы.',
+    byGroup: 'По группам',
+    byPrinter: 'По принтерам',
+    searchGroups: 'Поиск групп',
+    groupFilter: {
+      all: 'Все',
+      limited: 'Ограниченные',
+      open: 'Без ограничений',
+    },
+    noGroupsMatch: 'Подходящих групп нет.',
+    alwaysAll: 'Всегда все принтеры',
+    notLimited: 'Все принтеры',
+    reachShort: '{{count}} из {{total}} принтеров',
+    unsaved: 'Несохранённые изменения',
+    pickGroup: 'Выберите группу, чтобы задать её принтеры.',
+    memberCount: 'Участников: {{count}}',
+    editGroup: 'Изменить группу',
+    adminsSeeAll: 'Администраторы всегда видят все принтеры.',
+    limit: 'Ограничить участников выбранными здесь принтерами и расположениями',
+    limitHint: 'Выключено: эта группа не ограничивает, какие принтеры видят её участники.',
+    reach: 'Участники могут использовать {{count}} из {{total}} принтеров.',
+    noneGranted: 'Ничего не выбрано: участники этой группы не увидят ни одного принтера.',
+    noPrinters: 'Принтеры ещё не добавлены.',
+    searchPrinters: 'Поиск по имени, модели, серийному номеру или расположению',
+    allLocations: 'Все расположения',
+    noLocation: 'Без расположения',
+    allModels: 'Все модели',
+    accessFilter: 'Доступ',
+    accessAll: 'С доступом и без',
+    accessWith: 'С доступом',
+    accessWithout: 'Без доступа',
+    tickShown: 'Отметить все показанные',
+    untickShown: 'Снять отметку со всех показанных',
+    emptyLocation: 'сейчас здесь нет принтеров',
+    wholeLocation: 'Всё расположение',
+    wholeLocationHint: 'Все принтеры в {{location}}, включая добавленные туда позже',
+    noPrintersMatch: 'Подходящих принтеров нет.',
+    viaLocation: 'через расположение',
+    alsoVisible: 'Помимо перечисленных здесь групп, каждый принтер виден администраторам и пользователям, не состоящим ни в одной ограниченной группе.',
+    openUsers: 'Пользователи вне ограниченных групп: {{names}}',
+    noOpenUsers: 'Все пользователи, кроме администраторов, состоят в ограниченной группе.',
+    noLimitedGroups: 'Пока ни одна группа не ограничена, поэтому каждый пользователь видит все принтеры. Ограничьте группу на вкладке «По группам».',
+    noLimitedGroupReaches: 'Нет ограниченных групп',
+    showingOne: 'Показан один принтер.',
+    showAll: 'Показать все принтеры',
+    viaLocationTitle: 'Через расположение {{location}}. Измените это на вкладке «По группам».',
+    removeGroup: 'Убрать доступ для {{group}}',
+    addGroup: 'Предоставить доступ…',
+    changedGroups: 'Несохранённые изменения: {{names}}',
+    discard: 'Отменить',
+    saved: 'Доступ к принтерам сохранён',
+    whoHasAccess: 'У кого есть доступ',
+    moveWarning: 'Перемещение этого принтера в другое расположение изменит, кто может им пользоваться: {{groups}}.',
+  },
+
   users: {
     title: "Управление пользователями",
     subtitle: "Управление пользователями и их доступом к Bambuddy",
@@ -5133,6 +5200,7 @@ export default {
     invalidDateTime: "Введите корректные дату и время",
     openCalendar: "Открыть календарь",
     requireManualStart: "Требовать ручного запуска",
+    awaitingReviewNote: "Ваша печать ожидает проверки: она начнётся, когда её запустит тот, кто управляет очередью.",
     requirePreviousSuccess: "Запускать только после успешной предыдущей печати",
     autoOffAfter: "Выключить принтер после завершения",
     helpAsap: "Задание будет добавлено в начало очереди и запустится, как только подходящий принтер освободится.",

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

@@ -1574,6 +1574,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: 'Väntar på granskning',
       staged: 'Förberedd',
       requiresPrevious: 'Kräver tidigare framgång',
       autoPowerOff: 'Auto-avstängning',
@@ -1712,6 +1713,7 @@ export default {
     permissions: {
       noStopPrint: 'Du har inte behörighet att stoppa utskrifter',
       noStartPrint: 'Du har inte behörighet att starta utskrifter',
+      awaitingReview: 'Väntar på granskning: någon som hanterar kön startar det här jobbet',
       noEdit: 'Du har inte behörighet att redigera denna köpost',
       noCancel: 'Du har inte behörighet att avbryta denna köpost',
       noRequeue: 'Du har inte behörighet att lägga tillbaka poster i kön',
@@ -3619,9 +3621,73 @@ errors: {
       permissionsSelected: '{{count}} valda',
       noResults: 'Inga behörigheter matchar din sökning',
       websocketHint: 'Krävs för liveuppdateringar. Utan det faller gränssnittet tillbaka på periodisk avfrågning.',
+      printerAccess: 'Skrivaråtkomst',
+      adminsSeeAllPrinters: 'Administratörer ser alltid alla skrivare.',
+      printerAccessAfterCreate: 'När gruppen har skapats väljer du dess skrivare under Inställningar → Autentisering → Skrivaråtkomst.',
+      printerAccessLimited: 'Begränsad: {{printers}} skrivare valda, {{locations}} platser',
+      printerAccessOpen: 'Inte begränsad: medlemmar ser alla skrivare.',
+      managePrinterAccess: 'Hantera skrivaråtkomst',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Skrivaråtkomst',
+    intro: 'Välj vilka skrivare medlemmarna i varje grupp får se och styra. En grupp som inte är begränsad begränsar ingenting: en användare som inte är med i någon begränsad grupp ser alla skrivare, och administratörer gör det alltid.',
+    byGroup: 'Per grupp',
+    byPrinter: 'Per skrivare',
+    searchGroups: 'Sök grupper',
+    groupFilter: {
+      all: 'Alla',
+      limited: 'Begränsade',
+      open: 'Inte begränsade',
+    },
+    noGroupsMatch: 'Inga grupper matchar.',
+    alwaysAll: 'Alltid alla skrivare',
+    notLimited: 'Alla skrivare',
+    reachShort: '{{count}} av {{total}} skrivare',
+    unsaved: 'Osparade ändringar',
+    pickGroup: 'Välj en grupp för att välja dess skrivare.',
+    memberCount: 'Medlemmar: {{count}}',
+    editGroup: 'Redigera grupp',
+    adminsSeeAll: 'Administratörer ser alltid alla skrivare.',
+    limit: 'Begränsa medlemmar till de skrivare och platser som väljs här',
+    limitHint: 'Av: den här gruppen begränsar inte vilka skrivare medlemmarna ser.',
+    reach: 'Medlemmar kan använda {{count}} av {{total}} skrivare.',
+    noneGranted: 'Inget valt: medlemmar i den här gruppen ser ingen skrivare.',
+    noPrinters: 'Inga skrivare har lagts till än.',
+    searchPrinters: 'Sök namn, modell, serienummer eller plats',
+    allLocations: 'Alla platser',
+    noLocation: 'Ingen plats',
+    allModels: 'Alla modeller',
+    accessFilter: 'Åtkomst',
+    accessAll: 'Med och utan åtkomst',
+    accessWith: 'Med åtkomst',
+    accessWithout: 'Utan åtkomst',
+    tickShown: 'Markera alla visade',
+    untickShown: 'Avmarkera alla visade',
+    emptyLocation: 'ingen skrivare finns här just nu',
+    wholeLocation: 'Hela platsen',
+    wholeLocationHint: 'Alla skrivare i {{location}}, även skrivare som läggs till där senare',
+    noPrintersMatch: 'Inga skrivare matchar.',
+    viaLocation: 'via plats',
+    alsoVisible: 'Utöver grupperna som listas här är varje skrivare synlig för administratörer och för användare som inte är med i någon begränsad grupp.',
+    openUsers: 'Användare utan begränsad grupp: {{names}}',
+    noOpenUsers: 'Alla användare utom administratörer är med i en begränsad grupp.',
+    noLimitedGroups: 'Ingen grupp är begränsad än, så alla användare ser alla skrivare. Begränsa en grupp under "Per grupp".',
+    noLimitedGroupReaches: 'Ingen begränsad grupp',
+    showingOne: 'Visar en skrivare.',
+    showAll: 'Visa alla skrivare',
+    viaLocationTitle: 'Via platsen {{location}}. Ändra det under "Per grupp".',
+    removeGroup: 'Ta bort åtkomst för {{group}}',
+    addGroup: 'Ge åtkomst till…',
+    changedGroups: 'Osparade ändringar: {{names}}',
+    discard: 'Förkasta',
+    saved: 'Skrivaråtkomst sparad',
+    whoHasAccess: 'Vem har åtkomst',
+    moveWarning: 'Om du flyttar skrivaren till en annan plats ändras vem som kan använda den: {{groups}}.',
+  },
+
   // Users management
   users: {
     title: 'Användarhantering',
@@ -5419,6 +5485,7 @@ errors: {
     invalidDateTime: 'Ange ett giltigt datum och tid',
     openCalendar: 'Öppna kalender',
     requireManualStart: 'Kräv manuell start',
+    awaitingReviewNote: 'Din utskrift väntar på granskning: den börjar när någon som hanterar kön startar den.',
     requirePreviousSuccess: 'Starta endast om föregående utskrift lyckades',
     autoOffAfter: 'Stäng av skrivaren när den är klar',
     helpAsap: 'Utskriften kommer att läggas till i toppen av kön och starta så snart en lämplig skrivare är ledig.',

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

@@ -1558,6 +1558,7 @@ export default {
     },
     // Rozetler
     badges: {
+      awaitingReview: 'İnceleme bekliyor',
       staged: 'Hazırlandı',
       requiresPrevious: 'Önceki başarı gerekli',
       autoPowerOff: 'Otomatik kapanma',
@@ -1695,6 +1696,7 @@ export default {
     permissions: {
       noStopPrint: 'Baskıları durdurma izniniz yok',
       noStartPrint: 'Baskıları başlatma izniniz yok',
+      awaitingReview: 'İnceleme bekliyor: bu işi kuyruğu yöneten biri başlatır',
       noEdit: 'Bu kuyruk öğesini düzenleme izniniz yok',
       noCancel: 'Bu kuyruk öğesini iptal etme izniniz yok',
       noRequeue: 'Öğeleri yeniden kuyruklama izniniz yok',
@@ -3592,9 +3594,73 @@ export default {
       permissionsSelected: '{{count}} seçildi',
       noResults: 'Aramanızla eşleşen izin yok',
       websocketHint: 'Canlı güncellemeler için gereklidir. Bu izin olmadan arayüz düzenli yoklamaya geri döner.',
+      printerAccess: 'Yazıcı erişimi',
+      adminsSeeAllPrinters: 'Yöneticiler her zaman tüm yazıcıları görür.',
+      printerAccessAfterCreate: 'Grup oluşturulduktan sonra yazıcılarını Ayarlar → Kimlik Doğrulama → Yazıcı erişimi bölümünden seçin.',
+      printerAccessLimited: 'Sınırlı: {{printers}} yazıcı seçildi, {{locations}} konum',
+      printerAccessOpen: 'Sınırsız: üyeler tüm yazıcıları görür.',
+      managePrinterAccess: 'Yazıcı erişimini yönet',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Yazıcı erişimi',
+    intro: 'Her grubun üyelerinin hangi yazıcıları görüp kontrol edebileceğini seçin. Sınırlı olmayan bir grup hiçbir şeyi daraltmaz: hiçbir sınırlı grupta olmayan bir kullanıcı tüm yazıcıları görür, yöneticiler de her zaman görür.',
+    byGroup: 'Gruba göre',
+    byPrinter: 'Yazıcıya göre',
+    searchGroups: 'Grup ara',
+    groupFilter: {
+      all: 'Tümü',
+      limited: 'Sınırlı',
+      open: 'Sınırsız',
+    },
+    noGroupsMatch: 'Eşleşen grup yok.',
+    alwaysAll: 'Her zaman tüm yazıcılar',
+    notLimited: 'Tüm yazıcılar',
+    reachShort: '{{total}} yazıcıdan {{count}}',
+    unsaved: 'Kaydedilmemiş değişiklikler',
+    pickGroup: 'Yazıcılarını seçmek için bir grup seçin.',
+    memberCount: 'Üyeler: {{count}}',
+    editGroup: 'Grubu düzenle',
+    adminsSeeAll: 'Yöneticiler her zaman tüm yazıcıları görür.',
+    limit: 'Üyeleri burada seçilen yazıcılar ve konumlarla sınırla',
+    limitHint: 'Kapalı: bu grup, üyelerinin hangi yazıcıları gördüğünü sınırlamaz.',
+    reach: 'Üyeler {{total}} yazıcıdan {{count}} tanesini kullanabilir.',
+    noneGranted: 'Hiçbir şey seçilmedi: bu grubun üyeleri hiçbir yazıcıyı görmeyecek.',
+    noPrinters: 'Henüz yazıcı eklenmedi.',
+    searchPrinters: 'Ad, model, seri numarası veya konum ara',
+    allLocations: 'Tüm konumlar',
+    noLocation: 'Konum yok',
+    allModels: 'Tüm modeller',
+    accessFilter: 'Erişim',
+    accessAll: 'Erişimi olan ve olmayan',
+    accessWith: 'Erişimi olan',
+    accessWithout: 'Erişimi olmayan',
+    tickShown: 'Gösterilenlerin tümünü işaretle',
+    untickShown: 'Gösterilenlerin tümünün işaretini kaldır',
+    emptyLocation: 'şu anda burada yazıcı yok',
+    wholeLocation: 'Tüm konum',
+    wholeLocationHint: '{{location}} içindeki tüm yazıcılar, sonradan eklenenler dahil',
+    noPrintersMatch: 'Eşleşen yazıcı yok.',
+    viaLocation: 'konum üzerinden',
+    alsoVisible: 'Burada listelenen grupların yanı sıra, her yazıcı yöneticilere ve hiçbir sınırlı grupta olmayan kullanıcılara görünür.',
+    openUsers: 'Hiçbir sınırlı grupta olmayan kullanıcılar: {{names}}',
+    noOpenUsers: 'Yöneticiler dışındaki tüm kullanıcılar sınırlı bir gruptadır.',
+    noLimitedGroups: 'Henüz sınırlı grup yok, bu nedenle her kullanıcı tüm yazıcıları görür. Bir grubu "Gruba göre" bölümünden sınırlayın.',
+    noLimitedGroupReaches: 'Sınırlı grup yok',
+    showingOne: 'Bir yazıcı gösteriliyor.',
+    showAll: 'Tüm yazıcıları göster',
+    viaLocationTitle: '{{location}} konumu üzerinden. "Gruba göre" bölümünden değiştirin.',
+    removeGroup: '{{group}} için erişimi kaldır',
+    addGroup: 'Erişim ver…',
+    changedGroups: 'Kaydedilmemiş değişiklikler: {{names}}',
+    discard: 'Vazgeç',
+    saved: 'Yazıcı erişimi kaydedildi',
+    whoHasAccess: 'Kimin erişimi var',
+    moveWarning: 'Bu yazıcıyı başka bir konuma taşımak, onu kimin kullanabileceğini değiştirir: {{groups}}.',
+  },
+
   // Kullanıcı yönetimi
   users: {
     title: 'Kullanıcı Yönetimi',
@@ -5351,6 +5417,7 @@ export default {
     invalidDateTime: 'Lütfen geçerli bir tarih ve saat girin',
     openCalendar: 'Takvimi aç',
     requireManualStart: 'Manuel başlatma gerektir',
+    awaitingReviewNote: 'Baskınız incelemeyi bekliyor: kuyruğu yöneten biri başlattığında başlar.',
     requirePreviousSuccess: 'Yalnızca önceki baskı başarılıysa başlat',
     autoOffAfter: 'Bittiğinde yazıcıyı kapat',
     helpAsap: 'Baskı kuyruğun en üstüne eklenir ve uygun bir yazıcı boştaysa başlar.',

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

@@ -1573,6 +1573,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: 'Очікує перевірки',
       staged: "Очікує ручного запуску",
       requiresPrevious: "Потрібне успішне завершення попереднього завдання",
       autoPowerOff: "Автоматичне вимкнення",
@@ -1711,6 +1712,7 @@ export default {
     permissions: {
       noStopPrint: "Ви не маєте дозволу зупиняти друк",
       noStartPrint: "Ви не маєте дозволу розпочинати друк",
+      awaitingReview: "Очікує перевірки: це завдання запускає той, хто керує чергою",
       noEdit: "Ви не маєте дозволу редагувати цей елемент черги",
       noCancel: "Ви не маєте дозволу скасувати цей елемент черги",
       noRequeue: "Ви не маєте дозволу повторно ставити елементи в чергу",
@@ -3617,9 +3619,73 @@ export default {
       permissionsSelected: "Вибрано: {{count}}",
       noResults: "Немає дозволів, що відповідають вашому пошуку",
       websocketHint: "Необхідний для оновлень у реальному часі. Без нього інтерфейс повертається до періодичного опитування.",
+      printerAccess: 'Доступ до принтерів',
+      adminsSeeAllPrinters: 'Адміністратори завжди бачать усі принтери.',
+      printerAccessAfterCreate: 'Після створення групи виберіть її принтери в розділі Налаштування → Автентифікація → Доступ до принтерів.',
+      printerAccessLimited: 'Обмежена: вибрано принтерів — {{printers}}, розташувань — {{locations}}',
+      printerAccessOpen: 'Без обмежень: учасники бачать усі принтери.',
+      managePrinterAccess: 'Керувати доступом до принтерів',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: 'Доступ до принтерів',
+    intro: 'Виберіть, які принтери можуть бачити й використовувати учасники кожної групи. Група без обмежень нічого не звужує: користувач, який не входить до жодної обмеженої групи, бачить усі принтери, як і адміністратори.',
+    byGroup: 'За групами',
+    byPrinter: 'За принтерами',
+    searchGroups: 'Пошук груп',
+    groupFilter: {
+      all: 'Усі',
+      limited: 'Обмежені',
+      open: 'Без обмежень',
+    },
+    noGroupsMatch: 'Відповідних груп немає.',
+    alwaysAll: 'Завжди всі принтери',
+    notLimited: 'Усі принтери',
+    reachShort: '{{count}} з {{total}} принтерів',
+    unsaved: 'Незбережені зміни',
+    pickGroup: 'Виберіть групу, щоб задати її принтери.',
+    memberCount: 'Учасників: {{count}}',
+    editGroup: 'Редагувати групу',
+    adminsSeeAll: 'Адміністратори завжди бачать усі принтери.',
+    limit: 'Обмежити учасників вибраними тут принтерами й розташуваннями',
+    limitHint: 'Вимкнено: ця група не обмежує, які принтери бачать її учасники.',
+    reach: 'Учасники можуть використовувати {{count}} з {{total}} принтерів.',
+    noneGranted: 'Нічого не вибрано: учасники цієї групи не бачитимуть жодного принтера.',
+    noPrinters: 'Принтери ще не додано.',
+    searchPrinters: 'Пошук за назвою, моделлю, серійним номером або розташуванням',
+    allLocations: 'Усі розташування',
+    noLocation: 'Без розташування',
+    allModels: 'Усі моделі',
+    accessFilter: 'Доступ',
+    accessAll: 'З доступом і без',
+    accessWith: 'З доступом',
+    accessWithout: 'Без доступу',
+    tickShown: 'Позначити всі показані',
+    untickShown: 'Зняти позначку з усіх показаних',
+    emptyLocation: 'зараз тут немає принтерів',
+    wholeLocation: 'Усе розташування',
+    wholeLocationHint: 'Усі принтери в {{location}}, включно з доданими туди пізніше',
+    noPrintersMatch: 'Відповідних принтерів немає.',
+    viaLocation: 'через розташування',
+    alsoVisible: 'Окрім перелічених тут груп, кожен принтер бачать адміністратори та користувачі, які не входять до жодної обмеженої групи.',
+    openUsers: 'Користувачі поза обмеженими групами: {{names}}',
+    noOpenUsers: 'Усі користувачі, крім адміністраторів, входять до обмеженої групи.',
+    noLimitedGroups: 'Поки що жодна група не обмежена, тож кожен користувач бачить усі принтери. Обмежте групу на вкладці «За групами».',
+    noLimitedGroupReaches: 'Немає обмежених груп',
+    showingOne: 'Показано один принтер.',
+    showAll: 'Показати всі принтери',
+    viaLocationTitle: 'Через розташування {{location}}. Змініть це на вкладці «За групами».',
+    removeGroup: 'Прибрати доступ для {{group}}',
+    addGroup: 'Надати доступ…',
+    changedGroups: 'Незбережені зміни: {{names}}',
+    discard: 'Скасувати',
+    saved: 'Доступ до принтерів збережено',
+    whoHasAccess: 'Хто має доступ',
+    moveWarning: 'Переміщення цього принтера в інше розташування змінить, хто може ним користуватися: {{groups}}.',
+  },
+
   // Users management
   users: {
     title: "Керування користувачами",
@@ -5416,6 +5482,7 @@ export default {
     invalidDateTime: "Введіть дійсну дату й час",
     openCalendar: "Відкрити календар",
     requireManualStart: "Потрібен ручний запуск",
+    awaitingReviewNote: "Ваш друк очікує перевірки: він почнеться, коли його запустить той, хто керує чергою.",
     requirePreviousSuccess: "Починати, лише якщо попередній друк вдався",
     autoOffAfter: "Вимкнути принтер після завершення",
     helpAsap: "Завдання буде додано на початок черги й розпочнеться, щойно відповідний принтер звільниться.",

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

@@ -1558,6 +1558,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: '等待审核',
       staged: '已暂存',
       requiresPrevious: '需要前一个成功',
       autoPowerOff: '自动关机',
@@ -1695,6 +1696,7 @@ export default {
     permissions: {
       noStopPrint: '您没有停止打印的权限',
       noStartPrint: '您没有开始打印的权限',
+      awaitingReview: '等待审核:由管理队列的人启动此任务',
       noEdit: '您没有编辑此队列项目的权限',
       noCancel: '您没有取消此队列项目的权限',
       noRequeue: '您没有重新排队的权限',
@@ -3577,9 +3579,73 @@ export default {
       permissionsSelected: '已选 {{count}} 个',
       noResults: '没有权限匹配您的搜索',
       websocketHint: '实时更新所需。缺少此权限时,界面将回退到定期轮询。',
+      printerAccess: '打印机访问',
+      adminsSeeAllPrinters: '管理员始终可以看到所有打印机。',
+      printerAccessAfterCreate: '创建组后,请在 设置 → 身份验证 → 打印机访问 中选择其打印机。',
+      printerAccessLimited: '受限:已选 {{printers}} 台打印机,{{locations}} 个位置',
+      printerAccessOpen: '不受限:成员可以看到所有打印机。',
+      managePrinterAccess: '管理打印机访问',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: '打印机访问',
+    intro: '选择每个组的成员可以查看和控制哪些打印机。不受限的组不会缩小任何范围:不属于任何受限组的用户可以看到所有打印机,管理员也始终如此。',
+    byGroup: '按组',
+    byPrinter: '按打印机',
+    searchGroups: '搜索组',
+    groupFilter: {
+      all: '全部',
+      limited: '受限',
+      open: '不受限',
+    },
+    noGroupsMatch: '没有匹配的组。',
+    alwaysAll: '始终为所有打印机',
+    notLimited: '所有打印机',
+    reachShort: '{{count}} / {{total}} 台打印机',
+    unsaved: '未保存的更改',
+    pickGroup: '选择一个组以设置其打印机。',
+    memberCount: '成员:{{count}}',
+    editGroup: '编辑组',
+    adminsSeeAll: '管理员始终可以看到所有打印机。',
+    limit: '将成员限制为此处选择的打印机和位置',
+    limitHint: '关闭:此组不限制其成员可以看到的打印机。',
+    reach: '成员可以使用 {{total}} 台打印机中的 {{count}} 台。',
+    noneGranted: '未选择任何内容:该组成员将看不到任何打印机。',
+    noPrinters: '尚未添加打印机。',
+    searchPrinters: '搜索名称、型号、序列号或位置',
+    allLocations: '所有位置',
+    noLocation: '无位置',
+    allModels: '所有型号',
+    accessFilter: '访问',
+    accessAll: '有访问权限和无访问权限',
+    accessWith: '有访问权限',
+    accessWithout: '无访问权限',
+    tickShown: '勾选所有显示项',
+    untickShown: '取消勾选所有显示项',
+    emptyLocation: '目前此处没有打印机',
+    wholeLocation: '整个位置',
+    wholeLocationHint: '{{location}} 中的所有打印机,包括以后添加到此处的打印机',
+    noPrintersMatch: '没有匹配的打印机。',
+    viaLocation: '通过位置',
+    alsoVisible: '除此处列出的组外,每台打印机对管理员以及不属于任何受限组的用户都可见。',
+    openUsers: '不属于任何受限组的用户:{{names}}',
+    noOpenUsers: '除管理员外,所有用户都属于某个受限组。',
+    noLimitedGroups: '尚无受限组,因此每个用户都能看到所有打印机。请在“按组”中限制组。',
+    noLimitedGroupReaches: '没有受限组',
+    showingOne: '正在显示一台打印机。',
+    showAll: '显示所有打印机',
+    viaLocationTitle: '通过位置 {{location}}。请在“按组”中更改。',
+    removeGroup: '移除 {{group}} 的访问权限',
+    addGroup: '授予访问权限给…',
+    changedGroups: '未保存的更改:{{names}}',
+    discard: '放弃',
+    saved: '打印机访问已保存',
+    whoHasAccess: '谁有访问权限',
+    moveWarning: '将此打印机移到其他位置会改变可以使用它的组:{{groups}}。',
+  },
+
   // Users management
   users: {
     title: '用户管理',
@@ -5362,6 +5428,7 @@ export default {
     invalidDateTime: '请输入有效的日期和时间',
     openCalendar: '打开日历',
     requireManualStart: '要求手动开始',
+    awaitingReviewNote: '你的打印正在等待审核:管理队列的人启动后才会开始。',
     requirePreviousSuccess: '仅在上一次打印成功后开始',
     autoOffAfter: '完成后关闭打印机',
     helpAsap: '打印将添加到队列顶部,并在符合条件的打印机空闲后立即开始。',

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

@@ -1558,6 +1558,7 @@ export default {
     },
     // Badges
     badges: {
+      awaitingReview: '等待審核',
       staged: '已暫存',
       requiresPrevious: '需要前一個成功',
       autoPowerOff: '自動關機',
@@ -1695,6 +1696,7 @@ export default {
     permissions: {
       noStopPrint: '您沒有停止列印的權限',
       noStartPrint: '您沒有開始列印的權限',
+      awaitingReview: '等待審核:由管理佇列的人啟動此工作',
       noEdit: '您沒有編輯此佇列項目的權限',
       noCancel: '您沒有取消此佇列項目的權限',
       noRequeue: '您沒有重新佇列的權限',
@@ -3577,9 +3579,73 @@ export default {
       permissionsSelected: '已選 {{count}} 個',
       noResults: '沒有權限匹配您的搜尋',
       websocketHint: '即時更新所需。缺少此權限時,介面將回退到定期輪詢。',
+      printerAccess: '印表機存取',
+      adminsSeeAllPrinters: '管理員一律可以看到所有印表機。',
+      printerAccessAfterCreate: '建立群組後,請在 設定 → 身份驗證 → 印表機存取 中選擇其印表機。',
+      printerAccessLimited: '受限:已選 {{printers}} 台印表機,{{locations}} 個位置',
+      printerAccessOpen: '不受限:成員可以看到所有印表機。',
+      managePrinterAccess: '管理印表機存取',
     },
   },
 
+  // Printer access per group (#1727)
+  printerAccess: {
+    tab: '印表機存取',
+    intro: '選擇每個群組的成員可以檢視和控制哪些印表機。不受限的群組不會縮小任何範圍:不屬於任何受限群組的使用者可以看到所有印表機,管理員也一律如此。',
+    byGroup: '依群組',
+    byPrinter: '依印表機',
+    searchGroups: '搜尋群組',
+    groupFilter: {
+      all: '全部',
+      limited: '受限',
+      open: '不受限',
+    },
+    noGroupsMatch: '沒有符合的群組。',
+    alwaysAll: '一律為所有印表機',
+    notLimited: '所有印表機',
+    reachShort: '{{count}} / {{total}} 台印表機',
+    unsaved: '未儲存的變更',
+    pickGroup: '選擇一個群組以設定其印表機。',
+    memberCount: '成員:{{count}}',
+    editGroup: '編輯群組',
+    adminsSeeAll: '管理員一律可以看到所有印表機。',
+    limit: '將成員限制為此處選擇的印表機和位置',
+    limitHint: '關閉:此群組不限制其成員可以看到的印表機。',
+    reach: '成員可以使用 {{total}} 台印表機中的 {{count}} 台。',
+    noneGranted: '未選擇任何項目:此群組的成員將看不到任何印表機。',
+    noPrinters: '尚未新增印表機。',
+    searchPrinters: '搜尋名稱、型號、序號或位置',
+    allLocations: '所有位置',
+    noLocation: '無位置',
+    allModels: '所有型號',
+    accessFilter: '存取',
+    accessAll: '有存取權和無存取權',
+    accessWith: '有存取權',
+    accessWithout: '無存取權',
+    tickShown: '勾選所有顯示項目',
+    untickShown: '取消勾選所有顯示項目',
+    emptyLocation: '目前此處沒有印表機',
+    wholeLocation: '整個位置',
+    wholeLocationHint: '{{location}} 中的所有印表機,包括日後新增到此處的印表機',
+    noPrintersMatch: '沒有符合的印表機。',
+    viaLocation: '透過位置',
+    alsoVisible: '除此處列出的群組外,每台印表機對管理員以及不屬於任何受限群組的使用者皆可見。',
+    openUsers: '不屬於任何受限群組的使用者:{{names}}',
+    noOpenUsers: '除管理員外,所有使用者都屬於某個受限群組。',
+    noLimitedGroups: '尚無受限群組,因此每位使用者都能看到所有印表機。請在「依群組」中限制群組。',
+    noLimitedGroupReaches: '沒有受限群組',
+    showingOne: '正在顯示一台印表機。',
+    showAll: '顯示所有印表機',
+    viaLocationTitle: '透過位置 {{location}}。請在「依群組」中變更。',
+    removeGroup: '移除 {{group}} 的存取權',
+    addGroup: '授予存取權給…',
+    changedGroups: '未儲存的變更:{{names}}',
+    discard: '捨棄',
+    saved: '印表機存取已儲存',
+    whoHasAccess: '誰有存取權',
+    moveWarning: '將此印表機移到其他位置會改變可以使用它的群組:{{groups}}。',
+  },
+
   // Users management
   users: {
     title: '使用者管理',
@@ -5362,6 +5428,7 @@ export default {
     invalidDateTime: '請輸入有效的日期和時間',
     openCalendar: '開啟日曆',
     requireManualStart: '要求手動開始',
+    awaitingReviewNote: '你的列印正在等待審核:管理佇列的人啟動後才會開始。',
     requirePreviousSuccess: '僅在上一次列印成功後開始',
     autoOffAfter: '完成後關閉印表機',
     helpAsap: '列印將新增到佇列頂端,並在符合條件的印表機閒置後立即開始。',

+ 1 - 1
frontend/src/lib/settingsSearch.ts

@@ -24,7 +24,7 @@ export type SettingsSearchTab =
   | 'backup'
   | 'failure-detection';
 
-export type SettingsSearchSubTab = 'users' | 'email' | 'ldap' | 'oidc' | 'twofa' | 'security';
+export type SettingsSearchSubTab = 'users' | 'printer-access' | 'email' | 'ldap' | 'oidc' | 'twofa' | 'security';
 
 export type UsersSubTab = SettingsSearchSubTab;
 

+ 44 - 8
frontend/src/pages/GroupEditPage.tsx

@@ -1,10 +1,10 @@
 import { useState, useMemo } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
+import { Link, useParams, useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { ArrowLeft, Save, Loader2, Search, Check, Minus, Shield, AlertTriangle } from 'lucide-react';
+import { ArrowLeft, Save, Loader2, Search, Check, Minus, Shield, AlertTriangle, Printer as PrinterIcon } from 'lucide-react';
 import { api } from '../api/client';
-import type { Permission, PermissionCategory } from '../api/client';
+import type { GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client';
 import { Button } from '../components/Button';
 import { Card } from '../components/Card';
 import { useToast } from '../contexts/ToastContext';
@@ -43,8 +43,7 @@ export function GroupEditPage() {
   }
 
   const createMutation = useMutation({
-    mutationFn: (data: { name: string; description?: string; permissions: Permission[] }) =>
-      api.createGroup(data),
+    mutationFn: (data: GroupCreate) => api.createGroup(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       queryClient.invalidateQueries({ queryKey: ['group'] });
@@ -57,8 +56,7 @@ export function GroupEditPage() {
   });
 
   const updateMutation = useMutation({
-    mutationFn: (data: { name?: string; description?: string; permissions: Permission[] }) =>
-      api.updateGroup(Number(id), data),
+    mutationFn: (data: GroupUpdate) => api.updateGroup(Number(id), data),
     onSuccess: (updatedGroup) => {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       // Prime the single-group detail cache with the PATCH response body so
@@ -81,6 +79,8 @@ export function GroupEditPage() {
   });
 
   const isSaving = createMutation.isPending || updateMutation.isPending;
+  // Administrators see every printer regardless
+  const isAdministrators = isEditing && groupData?.is_system === true && groupData.name === 'Administrators';
 
   const handleSave = () => {
     if (!name.trim()) {
@@ -88,10 +88,16 @@ export function GroupEditPage() {
       return;
     }
     if (isEditing) {
+      // System groups refuse any permissions payload, so only send it when it
+      // changed -- otherwise their description couldn't be saved at all.
+      const permissionsChanged =
+        !groupData ||
+        permissions.length !== groupData.permissions.length ||
+        permissions.some((p) => !groupData.permissions.includes(p));
       updateMutation.mutate({
         name: name !== groupData?.name ? name : undefined,
         description,
-        permissions,
+        permissions: groupData?.is_system && !permissionsChanged ? undefined : permissions,
       });
     } else {
       createMutation.mutate({
@@ -207,6 +213,36 @@ export function GroupEditPage() {
         </div>
       </div>
 
+      {/* Printer access (#1727) is managed on its own page */}
+      <Card>
+        <div className="p-4 flex flex-wrap items-center justify-between gap-3">
+          <div className="flex items-center gap-2 min-w-0">
+            <PrinterIcon className="w-4 h-4 text-bambu-gray shrink-0" />
+            <span className="text-white font-medium text-sm">{t('groups.editor.printerAccess')}</span>
+            <span className="text-sm text-bambu-gray">
+              {isAdministrators
+                ? t('groups.editor.adminsSeeAllPrinters')
+                : !isEditing
+                ? t('groups.editor.printerAccessAfterCreate')
+                : groupData?.restrict_printers
+                ? t('groups.editor.printerAccessLimited', {
+                    printers: groupData.printer_ids.length,
+                    locations: groupData.locations?.length ?? 0,
+                  })
+                : t('groups.editor.printerAccessOpen')}
+            </span>
+          </div>
+          {isEditing && !isAdministrators && (
+            <Link
+              to={`/settings?tab=users&sub=printer-access&group=${id}`}
+              className="text-sm text-bambu-green hover:underline shrink-0"
+            >
+              {t('groups.editor.managePrinterAccess')}
+            </Link>
+          )}
+        </div>
+      </Card>
+
       {/* Toolbar */}
       <div className="flex items-center justify-between flex-wrap gap-3">
         <div className="flex items-center gap-3">

+ 42 - 2
frontend/src/pages/PrintersPage.tsx

@@ -157,6 +157,7 @@ import {
   PictureInPicture2,
   ThumbsUp,
   ThumbsDown,
+  KeyRound,
 } from 'lucide-react';
 import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
 
@@ -2113,7 +2114,7 @@ function PrinterCard({
   const queryClient = useQueryClient();
   const navigate = useNavigate();
   const { showToast } = useToast();
-  const { hasPermission, canModify } = useAuth();
+  const { hasPermission, canModify, authEnabled, isAdmin } = useAuth();
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [deleteArchives, setDeleteArchives] = useState(true);
@@ -3743,6 +3744,18 @@ function PrinterCard({
             <Info className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
             {t('printers.printerInformation')}
           </button>
+          {authEnabled && isAdmin && (
+            <button
+              className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
+              onClick={() => {
+                setShowMenu(false);
+                navigate(`/settings?tab=users&sub=printer-access&view=printers&printer=${printer.id}`);
+              }}
+            >
+              <KeyRound className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
+              {t('printerAccess.whoHasAccess')}
+            </button>
+          )}
           {/* Maintenance Mode toggle (#1476) — leverages backend is_active flag */}
           <button
             className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
@@ -8445,6 +8458,7 @@ function EditPrinterModal({
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const { authEnabled, isAdmin } = useAuth();
   const [form, setForm] = useState({
     name: printer.name,
     ip_address: printer.ip_address,
@@ -8455,6 +8469,25 @@ function EditPrinterModal({
     is_active: printer.is_active,
   });
 
+  // Groups can be given a location (#1727), so a move changes who can use the
+  // printer. Non-admins can't read groups; the server refuses their move instead.
+  const { data: groups } = useQuery({
+    queryKey: ['groups'],
+    queryFn: () => api.getGroups(),
+    enabled: authEnabled && isAdmin,
+  });
+  const newLocation = form.location.trim();
+  const accessChangedFor =
+    newLocation !== (printer.location || '')
+      ? (groups ?? [])
+          .filter(
+            (g) =>
+              g.restrict_printers &&
+              (g.locations ?? []).some((loc) => loc === printer.location || loc === newLocation)
+          )
+          .map((g) => g.name)
+      : [];
+
   // Setup-time pre-flight — same warn-on-save as the Add-Printer dialog, so an
   // edit that breaks connectivity (e.g. a mistyped IP) is caught before save.
   const [checkingSave, setCheckingSave] = useState(false);
@@ -8484,7 +8517,8 @@ function EditPrinterModal({
       name: form.name,
       ip_address: form.ip_address,
       model: form.model || undefined,
-      location: form.location || undefined,
+      // null clears it; leaving it out kept the old location
+      location: form.location.trim() || null,
       auto_archive: form.auto_archive,
       is_active: form.is_active,
     };
@@ -8615,6 +8649,12 @@ function EditPrinterModal({
                 maxLength={100}
               />
               <p className="text-xs text-bambu-gray mt-1">{t('printers.locationHelp')}</p>
+              {accessChangedFor.length > 0 && (
+                <p className="flex items-start gap-1.5 text-xs text-yellow-700 dark:text-yellow-400 mt-1">
+                  <AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-px" />
+                  {t('printerAccess.moveWarning', { groups: accessChangedFor.join(', ') })}
+                </p>
+              )}
             </div>
             <div className="flex items-center gap-2">
               <input

+ 12 - 3
frontend/src/pages/QueuePage.tsx

@@ -555,6 +555,9 @@ function SortableQueueItem({
   etaNow?: number;
   t: (key: string, options?: Record<string, unknown>) => string;
 }) {
+  const { hasAnyPermission } = useAuth();
+  // Their waiting jobs are started by someone who manages the queue (#1620)
+  const awaitingReview = !hasAnyPermission('queue:update_all', 'queue:start_unreviewed');
   const hasPhysicalAmsMapping =
     item.printer_id != null && (item.ams_mapping?.some((trayId) => trayId >= 0) ?? false);
 
@@ -941,7 +944,7 @@ function SortableQueueItem({
             {item.manual_start && (
               <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-purple-50 dark:bg-purple-500/10 text-purple-700 dark:text-purple-400 rounded-full border border-purple-200 dark:border-purple-500/20 flex items-center gap-1">
                 <Hand className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
-                {t('queue.badges.staged')}
+                {awaitingReview ? t('queue.badges.awaitingReview') : t('queue.badges.staged')}
               </span>
             )}
             {item.require_previous_success && (
@@ -1082,8 +1085,14 @@ function SortableQueueItem({
                     variant="ghost"
                     size="sm"
                     onClick={onStart}
-                    disabled={!canModify('queue', 'update', item.created_by_id)}
-                    title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
+                    disabled={awaitingReview || !canModify('queue', 'update', item.created_by_id)}
+                    title={
+                      awaitingReview
+                        ? t('queue.permissions.awaitingReview')
+                        : !canModify('queue', 'update', item.created_by_id)
+                          ? t('queue.permissions.noStartPrint')
+                          : t('queue.actions.startPrint')
+                    }
                     className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10 p-1.5 sm:p-2"
                   >
                     <Play className="w-4 h-4" />

+ 54 - 21
frontend/src/pages/SettingsPage.tsx

@@ -50,6 +50,7 @@ import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
 import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
+import { PrinterAccessSettings } from '../components/PrinterAccessSettings';
 import { TwoFactorSettings } from '../components/TwoFactorSettings';
 import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
 import { SecurityStatusCard } from '../components/SecurityStatusCard';
@@ -114,6 +115,7 @@ registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy'
 registerSettingsSearch({ labelKey: 'settings.currentUser', tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' });
 registerSettingsSearch({ labelKey: 'settings.users', tab: 'users', subTab: 'users', keywords: 'users accounts list', anchor: 'card-users' });
 registerSettingsSearch({ labelKey: 'settings.groups', tab: 'users', subTab: 'users', keywords: 'groups roles permissions administrators operators viewers', anchor: 'card-groups' });
+registerSettingsSearch({ labelKey: 'printerAccess.tab', tab: 'users', subTab: 'printer-access', keywords: 'printer access groups teams locations limit restrict scope who can see', anchor: 'card-printer-access' });
 registerSettingsSearch({ labelKey: 'settings.sessionPolicy.title', labelFallback: 'Session Policy', tab: 'users', subTab: 'users', keywords: 'session timeout expiry logout remember me jwt token lifetime', anchor: 'card-session-policy' });
 registerSettingsSearch({ labelKey: 'settings.email.smtpSettings', labelFallback: 'SMTP Configuration', tab: 'users', subTab: 'email', keywords: 'smtp email send server port password auth starttls ssl', anchor: 'card-smtp' });
 registerSettingsSearch({ labelKey: 'settings.ldap.title', labelFallback: 'LDAP Authentication', tab: 'users', subTab: 'ldap', keywords: 'ldap active directory ad authentication bind dn search base group mapping', anchor: 'card-ldap' });
@@ -285,7 +287,22 @@ export function SettingsPage() {
     : isLegacyCameraLink ? 'camera'
     : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
   const [activeTab, setActiveTab] = useState<TabType>(initialTab);
-  const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(isLegacyEmailTab ? 'email' : 'users');
+  // Only Printer access is deep-linked (?tab=users&sub=printer-access), from the printer card menu
+  const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(
+    isLegacyEmailTab ? 'email' : tabParam === 'users' && searchParams.get('sub') === 'printer-access' ? 'printer-access' : 'users'
+  );
+  const selectUsersSubTab = (sub: UsersSubTab) => {
+    setUsersSubTab(sub);
+    if (sub === 'printer-access') {
+      searchParams.set('sub', 'printer-access');
+    } else {
+      for (const key of ['sub', 'view', 'group', 'printer']) searchParams.delete(key);
+    }
+    setSearchParams(searchParams, { replace: true });
+  };
+  // A link to Printer access lands non-admins on the Users sub-tab instead
+  const shownUsersSubTab: UsersSubTab =
+    usersSubTab === 'printer-access' && !(authEnabled && isAdmin) ? 'users' : usersSubTab;
   // Workflow tab sub-tabs (#1425): 'dispatch' = current Workflow content,
   // 'pipelines' = Slicer Pipelines management. URL: ?tab=queue&sub=pipelines.
   const initialQueueSub: 'dispatch' | 'pipelines' =
@@ -298,9 +315,10 @@ export function SettingsPage() {
     if (tab === 'users') {
       setUsersSubTab('users');
     }
+    // Sub-tab state belongs to the tab being left
+    for (const key of ['sub', 'view', 'group', 'printer']) searchParams.delete(key);
     if (tab === 'queue') {
       setQueueSubTab('dispatch');
-      searchParams.delete('sub');
     }
     if (tab === 'general') {
       searchParams.delete('tab');
@@ -1551,7 +1569,7 @@ export function SettingsPage() {
   const jumpToSetting = (entry: typeof searchIndex[number]) => {
     handleTabChange(entry.tab as TabType);
     if (entry.subTab) {
-      setUsersSubTab(entry.subTab as UsersSubTab);
+      selectUsersSubTab(entry.subTab as UsersSubTab);
     }
     setSettingsSearch('');
     // Scroll to the card after the tab has rendered
@@ -6639,9 +6657,9 @@ export function SettingsPage() {
           {/* Sub-tab Navigation */}
           <div className="flex gap-1 border-b border-bambu-dark-tertiary">
             <button
-              onClick={() => setUsersSubTab('users')}
+              onClick={() => selectUsersSubTab('users')}
               className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-                usersSubTab === 'users'
+                shownUsersSubTab === 'users'
                   ? 'text-bambu-green border-bambu-green'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
               }`}
@@ -6649,10 +6667,23 @@ export function SettingsPage() {
               <Users className="w-4 h-4" />
               {t('settings.tabs.users')}
             </button>
+            {authEnabled && isAdmin && (
+              <button
+                onClick={() => selectUsersSubTab('printer-access')}
+                className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+                  shownUsersSubTab === 'printer-access'
+                    ? 'text-bambu-green border-bambu-green'
+                    : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+                }`}
+              >
+                <Printer className="w-4 h-4" />
+                {t('printerAccess.tab')}
+              </button>
+            )}
             <button
-              onClick={() => setUsersSubTab('email')}
+              onClick={() => selectUsersSubTab('email')}
               className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-                usersSubTab === 'email'
+                shownUsersSubTab === 'email'
                   ? 'text-bambu-green border-bambu-green'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
               }`}
@@ -6664,9 +6695,9 @@ export function SettingsPage() {
               )}
             </button>
             <button
-              onClick={() => setUsersSubTab('ldap')}
+              onClick={() => selectUsersSubTab('ldap')}
               className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-                usersSubTab === 'ldap'
+                shownUsersSubTab === 'ldap'
                   ? 'text-bambu-green border-bambu-green'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
               }`}
@@ -6678,9 +6709,9 @@ export function SettingsPage() {
               )}
             </button>
             <button
-              onClick={() => setUsersSubTab('twofa')}
+              onClick={() => selectUsersSubTab('twofa')}
               className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
-                usersSubTab === 'twofa'
+                shownUsersSubTab === 'twofa'
                   ? 'text-bambu-green border-bambu-green'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
               }`}
@@ -6697,9 +6728,9 @@ export function SettingsPage() {
             </button>
             {isAdmin && (
               <button
-                onClick={() => setUsersSubTab('oidc')}
+                onClick={() => selectUsersSubTab('oidc')}
                 className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
-                  usersSubTab === 'oidc'
+                  shownUsersSubTab === 'oidc'
                     ? 'text-bambu-green border-bambu-green'
                     : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
                 }`}
@@ -6717,9 +6748,9 @@ export function SettingsPage() {
             )}
             {isAdmin && (
               <button
-                onClick={() => setUsersSubTab('security')}
+                onClick={() => selectUsersSubTab('security')}
                 className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
-                  usersSubTab === 'security'
+                  shownUsersSubTab === 'security'
                     ? 'text-bambu-green border-bambu-green'
                     : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
                 }`}
@@ -6731,7 +6762,7 @@ export function SettingsPage() {
           </div>
 
           {/* Users Sub-tab */}
-          {usersSubTab === 'users' && (
+          {shownUsersSubTab === 'users' && (
           <>
           {/* Auth Toggle Header */}
           <Card>
@@ -7107,26 +7138,28 @@ export function SettingsPage() {
           </>
           )}
 
+          {shownUsersSubTab === 'printer-access' && <PrinterAccessSettings />}
+
           {/* Email Auth Sub-tab */}
-          {usersSubTab === 'email' && (
+          {shownUsersSubTab === 'email' && (
             <div className="max-w-5xl" id="card-smtp">
               <EmailSettings />
             </div>
           )}
 
-          {usersSubTab === 'ldap' && (
+          {shownUsersSubTab === 'ldap' && (
             <div className="max-w-5xl" id="card-ldap">
               <LDAPSettings />
             </div>
           )}
 
-          {usersSubTab === 'twofa' && (
+          {shownUsersSubTab === 'twofa' && (
             <div className="max-w-2xl">
               <TwoFactorSettings />
             </div>
           )}
 
-          {usersSubTab === 'oidc' && isAdmin && (
+          {shownUsersSubTab === 'oidc' && isAdmin && (
             <div className="max-w-3xl space-y-4">
               <Card>
                 <CardContent className="space-y-3 p-4">
@@ -7148,7 +7181,7 @@ export function SettingsPage() {
             </div>
           )}
 
-          {usersSubTab === 'security' && isAdmin && (
+          {shownUsersSubTab === 'security' && isAdmin && (
             <div className="max-w-3xl">
               <SecurityStatusCard />
             </div>

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


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


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


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-C5OR618T.css


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


File diff suppressed because it is too large
+ 1 - 0
static/assets/index-DKHAZk9g.css


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


+ 2 - 2
static/index.html

@@ -26,9 +26,9 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D2LwYQoy.js"></script>
+    <script type="module" crossorigin src="/assets/index-CSbs3wvg.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-C5OR618T.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-DKHAZk9g.css">
   </head>
   <body>
     <div id="root"></div>

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