Browse Source

Give groups whole locations and a printer access page (issue #1727)

A group can now be given locations as well as single printers: it then
reaches every printer in them, including printers added there later.
Printer access moves out of the group editor to Settings ->
Authentication -> Printer access, built for large fleets: groups and
printers searchable and filtered by location, model and access, edited
by group or by printer, saved together. "Who has access" in the printer
card menu opens it on that printer.

- group_locations table; the scope is picked printers plus printers
  whose location matches.
- Moving a printer into or out of a location a limited group has is
  admin-only, and open connections are rescoped. The edit dialog names
  the groups a move affects. Locations are trimmed on save.
- Clearing a printer's location in the edit dialog now clears it.
maziggy 5 days ago
parent
commit
fe07fbd07b
42 changed files with 2612 additions and 230 deletions
  1. 0 1
      CHANGELOG.md
  2. 60 9
      backend/app/api/routes/groups.py
  3. 22 2
      backend/app/api/routes/printers.py
  4. 48 4
      backend/app/core/printer_scope.py
  5. 2 1
      backend/app/models/__init__.py
  6. 12 0
      backend/app/models/group.py
  7. 5 1
      backend/app/schemas/group.py
  8. 15 0
      backend/app/schemas/printer.py
  9. 180 0
      backend/tests/integration/test_printer_scope_1727.py
  10. 131 0
      frontend/src/__tests__/components/EditPrinterLocationAccess.test.tsx
  11. 212 0
      frontend/src/__tests__/components/PrinterAccessSettings.test.tsx
  12. 31 39
      frontend/src/__tests__/pages/GroupEditPage.test.tsx
  13. 44 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  14. 6 2
      frontend/src/api/client.ts
  15. 787 0
      frontend/src/components/PrinterAccessSettings.tsx
  16. 62 4
      frontend/src/i18n/locales/de.ts
  17. 62 4
      frontend/src/i18n/locales/en.ts
  18. 62 4
      frontend/src/i18n/locales/es.ts
  19. 62 4
      frontend/src/i18n/locales/fr.ts
  20. 62 4
      frontend/src/i18n/locales/it.ts
  21. 62 4
      frontend/src/i18n/locales/ja.ts
  22. 63 4
      frontend/src/i18n/locales/ko.ts
  23. 62 4
      frontend/src/i18n/locales/nl.ts
  24. 62 4
      frontend/src/i18n/locales/pt-BR.ts
  25. 63 4
      frontend/src/i18n/locales/ru.ts
  26. 62 4
      frontend/src/i18n/locales/sv.ts
  27. 62 4
      frontend/src/i18n/locales/tr.ts
  28. 62 4
      frontend/src/i18n/locales/uk.ts
  29. 62 4
      frontend/src/i18n/locales/zh-CN.ts
  30. 62 4
      frontend/src/i18n/locales/zh-TW.ts
  31. 1 1
      frontend/src/lib/settingsSearch.ts
  32. 25 82
      frontend/src/pages/GroupEditPage.tsx
  33. 42 2
      frontend/src/pages/PrintersPage.tsx
  34. 54 21
      frontend/src/pages/SettingsPage.tsx
  35. 0 0
      static/assets/ImagePreviewModal-DyApVBYj.js
  36. 0 1
      static/assets/PdfPreviewModal-Dvt7jadd.js
  37. 0 0
      static/assets/SpreadsheetPreviewModal-CIOvGikV.js
  38. 0 1
      static/assets/index-DERg1WHl.js
  39. 1 0
      static/assets/index-DjfwCy3_.css
  40. 0 1
      static/assets/index-b50e2nk4.css
  41. 0 0
      static/assets/pdf-p87DKNLk.js
  42. 2 2
      static/index.html

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


+ 60 - 9
backend/app/api/routes/groups.py

@@ -12,9 +12,9 @@ from backend.app.core.permissions import (
     PERMISSION_CATEGORIES,
     PERMISSION_CATEGORIES,
     Permission,
     Permission,
 )
 )
-from backend.app.core.printer_scope import group_printer_ids
+from backend.app.core.printer_scope import group_location_names, group_printer_ids
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
-from backend.app.models.group import Group, group_printers
+from backend.app.models.group import Group, group_locations, group_printers
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.group import (
 from backend.app.schemas.group import (
@@ -62,8 +62,31 @@ async def _printer_ids_by_group(db: AsyncSession) -> dict[int, list[int]]:
     return {gid: sorted(pids) for gid, pids in by_group.items()}
     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(
 async def _apply_printer_scope(
-    db: AsyncSession, group: Group, restrict_printers: bool | None, printer_ids: list[int] | None
+    db: AsyncSession,
+    group: Group,
+    restrict_printers: bool | None,
+    printer_ids: list[int] | None,
+    locations: list[str] | None = None,
 ) -> bool:
 ) -> bool:
     """Validate and store a group's printer scope (#1727). Returns whether it changed.
     """Validate and store a group's printer scope (#1727). Returns whether it changed.
 
 
@@ -99,10 +122,25 @@ async def _apply_printer_scope(
                     insert(group_printers), [{"group_id": group.id, "printer_id": pid} for pid in sorted(wanted)]
                     insert(group_printers), [{"group_id": group.id, "printer_id": pid} for pid in sorted(wanted)]
                 )
                 )
             changed = True
             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
     return changed
 
 
 
 
-def _group_response(group: Group, printer_ids: list[int], user_count: int) -> GroupResponse:
+def _group_response(group: Group, printer_ids: list[int], locations: list[str], user_count: int) -> GroupResponse:
     return GroupResponse(
     return GroupResponse(
         id=group.id,
         id=group.id,
         name=group.name,
         name=group.name,
@@ -111,6 +149,7 @@ def _group_response(group: Group, printer_ids: list[int], user_count: int) -> Gr
         is_system=group.is_system,
         is_system=group.is_system,
         restrict_printers=bool(group.restrict_printers),
         restrict_printers=bool(group.restrict_printers),
         printer_ids=printer_ids,
         printer_ids=printer_ids,
+        locations=locations,
         user_count=user_count,
         user_count=user_count,
         created_at=group.created_at,
         created_at=group.created_at,
         updated_at=group.updated_at,
         updated_at=group.updated_at,
@@ -146,7 +185,13 @@ async def list_groups(
     result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
     result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
     groups = result.scalars().all()
     groups = result.scalars().all()
     printers_by_group = await _printer_ids_by_group(db)
     printers_by_group = await _printer_ids_by_group(db)
-    return [_group_response(group, printers_by_group.get(group.id, []), len(group.users)) for group in groups]
+    locations_by_group = await _locations_by_group(db)
+    return [
+        _group_response(
+            group, printers_by_group.get(group.id, []), locations_by_group.get(group.id, []), len(group.users)
+        )
+        for group in groups
+    ]
 
 
 
 
 @router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
 @router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
@@ -183,11 +228,11 @@ async def create_group(
     )
     )
     db.add(group)
     db.add(group)
     await db.flush()
     await db.flush()
-    await _apply_printer_scope(db, group, group_data.restrict_printers, group_data.printer_ids)
+    await _apply_printer_scope(db, group, group_data.restrict_printers, group_data.printer_ids, group_data.locations)
     await db.commit()
     await db.commit()
     await db.refresh(group)
     await db.refresh(group)
 
 
-    return _group_response(group, await group_printer_ids(db, group.id), 0)
+    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)
 @router.get("/{group_id}", response_model=GroupDetailResponse)
@@ -214,6 +259,7 @@ async def get_group(
         is_system=group.is_system,
         is_system=group.is_system,
         restrict_printers=bool(group.restrict_printers),
         restrict_printers=bool(group.restrict_printers),
         printer_ids=await group_printer_ids(db, group.id),
         printer_ids=await group_printer_ids(db, group.id),
+        locations=await group_location_names(db, group.id),
         user_count=len(group.users),
         user_count=len(group.users),
         created_at=group.created_at,
         created_at=group.created_at,
         updated_at=group.updated_at,
         updated_at=group.updated_at,
@@ -277,14 +323,18 @@ async def update_group(
             )
             )
         group.permissions = group_data.permissions
         group.permissions = group_data.permissions
 
 
-    scope_changed = await _apply_printer_scope(db, group, group_data.restrict_printers, group_data.printer_ids)
+    scope_changed = await _apply_printer_scope(
+        db, group, group_data.restrict_printers, group_data.printer_ids, group_data.locations
+    )
 
 
     await db.commit()
     await db.commit()
     await db.refresh(group)
     await db.refresh(group)
     if scope_changed:
     if scope_changed:
         await ws_manager.refresh_printer_scopes()
         await ws_manager.refresh_printer_scopes()
 
 
-    return _group_response(group, await group_printer_ids(db, group.id), len(group.users))
+    return _group_response(
+        group, await group_printer_ids(db, group.id), await group_location_names(db, group.id), len(group.users)
+    )
 
 
 
 
 @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
 @router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -312,6 +362,7 @@ async def delete_group(
     restricted = bool(group.restrict_printers)
     restricted = bool(group.restrict_printers)
     # SQLite doesn't enforce the FK cascade
     # 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_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.delete(group)
     await db.commit()
     await db.commit()
     if restricted:
     if restricted:

+ 22 - 2
backend/app/api/routes/printers.py

@@ -23,8 +23,9 @@ from backend.app.core.auth import (
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
-from backend.app.core.printer_scope import PrinterScope
+from backend.app.core.printer_scope import PrinterScope, location_grantees
 from backend.app.core.tasks import spawn_background_task
 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.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.models.slot_preset import SlotPresetMapping
@@ -202,6 +203,10 @@ async def create_printer(
     await db.commit()
     await db.commit()
     await db.refresh(printer)
     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
     # Connect to the printer
     if printer.is_active:
     if printer.is_active:
         await printer_manager.connect_printer(printer)
         await printer_manager.connect_printer(printer)
@@ -381,7 +386,7 @@ async def get_printer(
 async def update_printer(
 async def update_printer(
     printer_id: int,
     printer_id: int,
     printer_data: PrinterUpdate,
     printer_data: PrinterUpdate,
-    _=RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
+    user: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Update a printer."""
     """Update a printer."""
@@ -392,6 +397,18 @@ async def update_printer(
 
 
     update_data = printer_data.model_dump(exclude_unset=True)
     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
     # Handle nested ROI object - flatten to individual columns
     if "plate_detection_roi" in update_data:
     if "plate_detection_roi" in update_data:
         roi = update_data.pop("plate_detection_roi")
         roi = update_data.pop("plate_detection_roi")
@@ -413,6 +430,9 @@ async def update_printer(
     await db.commit()
     await db.commit()
     await db.refresh(printer)
     await db.refresh(printer)
 
 
+    if access_moved:
+        await ws_manager.refresh_printer_scopes()
+
     # Reconnect if connection settings changed
     # Reconnect if connection settings changed
     if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
     if any(k in update_data for k in ["ip_address", "access_code", "is_active"]):
         printer_manager.disconnect_printer(printer_id)
         printer_manager.disconnect_printer(printer_id)

+ 48 - 4
backend/app/core/printer_scope.py

@@ -8,7 +8,8 @@ How a scope is derived:
 
 
 * Auth disabled, or an admin user: every printer.
 * Auth disabled, or an admin user: every printer.
 * A user: the union of the printers of each of their groups that has
 * A user: the union of the printers of each of their groups that has
-  ``restrict_printers`` set. A user in no such group sees every printer, so
+  ``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
   installs that never configure this behave exactly as before. A group
   without the flag doesn't contribute, so permission groups (Operators,
   without the flag doesn't contribute, so permission groups (Operators,
   Viewers) combine with team groups without widening them.
   Viewers) combine with team groups without widening them.
@@ -28,7 +29,7 @@ from fastapi import HTTPException, status
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.models.group import Group, group_printers
+from backend.app.models.group import Group, group_locations, group_printers
 
 
 
 
 @dataclass(frozen=True)
 @dataclass(frozen=True)
@@ -108,6 +109,32 @@ async def group_printer_ids(db: AsyncSession, group_id: int) -> list[int]:
     return list(result.scalars().all())
     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:
 async def resolve_user_printer_scope(db: AsyncSession, user) -> PrinterScope:
     """Scope of a loaded ``User`` (``groups`` must already be loaded)."""
     """Scope of a loaded ``User`` (``groups`` must already be loaded)."""
     if user.is_admin:
     if user.is_admin:
@@ -115,8 +142,25 @@ async def resolve_user_printer_scope(db: AsyncSession, user) -> PrinterScope:
     restricted = [g.id for g in user.groups if g.restrict_printers]
     restricted = [g.id for g in user.groups if g.restrict_printers]
     if not restricted:
     if not restricted:
         return ALL_PRINTERS
         return ALL_PRINTERS
-    result = await db.execute(select(group_printers.c.printer_id).where(group_printers.c.group_id.in_(restricted)))
-    return PrinterScope(frozenset(result.scalars().all()))
+    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:
 async def resolve_user_id_printer_scope(db: AsyncSession, user_id: int | None) -> PrinterScope:

+ 2 - 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.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
-from backend.app.models.group import Group, group_printers, 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.kprofile_note import KProfileNote
 from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.local_preset import LocalPreset
@@ -76,6 +76,7 @@ __all__ = [
     "User",
     "User",
     "Group",
     "Group",
     "user_groups",
     "user_groups",
+    "group_locations",
     "group_printers",
     "group_printers",
     "GitHubBackupConfig",
     "GitHubBackupConfig",
     "GitHubBackupLog",
     "GitHubBackupLog",

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

@@ -34,6 +34,18 @@ group_printers = Table(
     Column("printer_id", Integer, ForeignKey("printers.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):
 class Group(Base):
     """Group model for organizing users and assigning permissions.
     """Group model for organizing users and assigning permissions.

+ 5 - 1
backend/app/schemas/group.py

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

+ 15 - 0
backend/app/schemas/printer.py

@@ -5,6 +5,17 @@ from pydantic import BaseModel, Field, field_validator, model_validator
 from backend.app.utils.printer_models import supports_nozzle_flow_type
 from backend.app.utils.printer_models import supports_nozzle_flow_type
 
 
 
 
+def _normalize_location(v: str | None) -> str | None:
+    """Trim the location; blank means none.
+
+    Groups can be given a location (#1727), matched exactly against this
+    field, so stray whitespace would quietly keep a printer out of it.
+    """
+    if v is None:
+        return None
+    return v.strip() or None
+
+
 class PrinterBase(BaseModel):
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     name: str = Field(..., min_length=1, max_length=100)
     serial_number: str = Field(..., min_length=1, max_length=50)
     serial_number: str = Field(..., min_length=1, max_length=50)
@@ -47,6 +58,8 @@ class PrinterCreate(PrinterBase):
     # connect to the printer's MQTT and bypass Bambuddy's RBAC.
     # connect to the printer's MQTT and bypass Bambuddy's RBAC.
     access_code: str = Field(..., min_length=1, max_length=20)
     access_code: str = Field(..., min_length=1, max_length=20)
 
 
+    _location = field_validator("location")(_normalize_location)
+
 
 
 class PlateDetectionROI(BaseModel):
 class PlateDetectionROI(BaseModel):
     """Region of interest for plate detection (percentages 0.0-1.0)."""
     """Region of interest for plate detection (percentages 0.0-1.0)."""
@@ -78,6 +91,8 @@ class PrinterUpdate(BaseModel):
     plate_detection_enabled: bool | None = None
     plate_detection_enabled: bool | None = None
     plate_detection_roi: PlateDetectionROI | None = None
     plate_detection_roi: PlateDetectionROI | None = None
 
 
+    _location = field_validator("location")(_normalize_location)
+
 
 
 class PrinterResponse(PrinterBase):
 class PrinterResponse(PrinterBase):
     id: int
     id: int

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

@@ -557,3 +557,183 @@ class TestByIdAndMedia:
 
 
         left = (await db_session.execute(select(PrintLogEntry.printer_id))).scalars().all()
         left = (await db_session.execute(select(PrintLogEntry.printer_id))).scalars().all()
         assert left == [b.id]
         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}

+ 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'));
+  });
+});

+ 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();
+  });
+});

+ 31 - 39
frontend/src/__tests__/pages/GroupEditPage.test.tsx

@@ -283,10 +283,7 @@ describe('GroupEditPage', () => {
 });
 });
 
 
 describe('GroupEditPage printer access (#1727)', () => {
 describe('GroupEditPage printer access (#1727)', () => {
-  const printers = [
-    { id: 1, name: 'Lab X1C' },
-    { id: 2, name: 'Training P1S' },
-  ];
+  // Printer access is edited on its own page; the editor only summarises it
   let posted: Record<string, unknown> | null;
   let posted: Record<string, unknown> | null;
   let patched: Record<string, unknown> | null;
   let patched: Record<string, unknown> | null;
 
 
@@ -295,7 +292,6 @@ describe('GroupEditPage printer access (#1727)', () => {
     patched = null;
     patched = null;
     server.use(
     server.use(
       http.get('/api/v1/groups/permissions', () => HttpResponse.json(mockPermissions)),
       http.get('/api/v1/groups/permissions', () => HttpResponse.json(mockPermissions)),
-      http.get('/api/v1/printers/', () => HttpResponse.json(printers)),
       http.get('/api/v1/groups/:id', () => HttpResponse.json(group)),
       http.get('/api/v1/groups/:id', () => HttpResponse.json(group)),
       http.post('/api/v1/groups/', async ({ request }) => {
       http.post('/api/v1/groups/', async ({ request }) => {
         posted = (await request.json()) as Record<string, unknown>;
         posted = (await request.json()) as Record<string, unknown>;
@@ -334,59 +330,55 @@ describe('GroupEditPage printer access (#1727)', () => {
     );
     );
   };
   };
 
 
-  it('sends the selected printers when creating a restricted group', async () => {
-    setup({});
+  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();
     const user = userEvent.setup();
-    render(<GroupEditPage />);
+    await renderEdit();
 
 
-    await waitFor(() => expect(screen.getByText('Printer access')).toBeInTheDocument());
-    await user.type(screen.getByPlaceholderText(/group name/i), 'Team A');
-    await user.click(screen.getByRole('switch'));
-    await user.click(await screen.findByLabelText('Lab X1C'));
+    await waitFor(() => expect(screen.getByDisplayValue('Operators')).toBeInTheDocument());
     await user.click(screen.getByText('Save'));
     await user.click(screen.getByText('Save'));
 
 
-    await waitFor(() => expect(posted).not.toBeNull());
-    expect(posted).toMatchObject({ name: 'Team A', restrict_printers: true, printer_ids: [1] });
+    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('warns when a restricted group has no printers', async () => {
+  it('points a new group to the access page', async () => {
     setup({});
     setup({});
     const user = userEvent.setup();
     const user = userEvent.setup();
     render(<GroupEditPage />);
     render(<GroupEditPage />);
 
 
     await waitFor(() => expect(screen.getByText('Printer access')).toBeInTheDocument());
     await waitFor(() => expect(screen.getByText('Printer access')).toBeInTheDocument());
-    expect(screen.queryByText(/won't see any printer/)).not.toBeInTheDocument();
-    await user.click(screen.getByRole('switch'));
-    expect(await screen.findByText(/won't see any printer/)).toBeInTheDocument();
-  });
-
-  it('saves printer access on a system group without resending its permissions', async () => {
-    setup({ ...mockGroup, restrict_printers: true, printer_ids: [2] });
-    const user = userEvent.setup();
-    await renderEdit();
-
-    await waitFor(() => expect(screen.getByDisplayValue('Operators')).toBeInTheDocument());
-    expect(screen.getByLabelText('Training P1S')).toBeChecked();
-    await user.click(screen.getByLabelText('Lab X1C'));
+    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 user.click(screen.getByText('Save'));
 
 
-    await waitFor(() => expect(patched).not.toBeNull());
-    expect(patched).toMatchObject({ restrict_printers: true, printer_ids: [2, 1] });
-    expect(patched).not.toHaveProperty('permissions');
+    await waitFor(() => expect(posted).not.toBeNull());
+    expect(posted).not.toHaveProperty('restrict_printers');
   });
   });
 
 
   it('explains that Administrators always see every printer', async () => {
   it('explains that Administrators always see every printer', async () => {
-    setup({ ...mockGroup, id: 1, name: 'Administrators', restrict_printers: false, printer_ids: [] });
-    const user = userEvent.setup();
+    setup({ ...mockGroup, id: 1, name: 'Administrators', restrict_printers: false, printer_ids: [], locations: [] });
     await renderEdit();
     await renderEdit();
 
 
     await waitFor(() => expect(screen.getByDisplayValue('Administrators')).toBeInTheDocument());
     await waitFor(() => expect(screen.getByDisplayValue('Administrators')).toBeInTheDocument());
     expect(screen.getByText('Administrators always see every printer.')).toBeInTheDocument();
     expect(screen.getByText('Administrators always see every printer.')).toBeInTheDocument();
-    expect(screen.queryByRole('switch')).not.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(screen.queryByRole('link', { name: 'Manage printer access' })).not.toBeInTheDocument();
   });
   });
 });
 });

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

@@ -1730,6 +1730,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)
   // Ask for the outcome of prints Bambuddy did not start (#1898)
   // --------------------------------------------------------------------
   // --------------------------------------------------------------------

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

@@ -731,7 +731,7 @@ export interface PrinterCreate {
   ip_address: string;
   ip_address: string;
   access_code: string;
   access_code: string;
   model?: string;
   model?: string;
-  location?: string;
+  location?: string | null;
   auto_archive?: boolean;
   auto_archive?: boolean;
   // Maintenance Mode flag (#1476). Backend already gates MQTT, queue dispatch,
   // Maintenance Mode flag (#1476). Backend already gates MQTT, queue dispatch,
   // scheduler, metrics and the print picker on this; toggling via PATCH
   // scheduler, metrics and the print picker on this; toggling via PATCH
@@ -4298,9 +4298,11 @@ export interface Group {
   description: string | null;
   description: string | null;
   permissions: Permission[];
   permissions: Permission[];
   is_system: boolean;
   is_system: boolean;
-  /** Members only see the printers in printer_ids (#1727) */
+  /** Members only see the printers in printer_ids plus every printer in locations (#1727) */
   restrict_printers: boolean;
   restrict_printers: boolean;
   printer_ids: number[];
   printer_ids: number[];
+  /** Matched against Printer.location, so printers added there later are included */
+  locations: string[];
   user_count: number;
   user_count: number;
   created_at: string;
   created_at: string;
   updated_at: string;
   updated_at: string;
@@ -4316,6 +4318,7 @@ export interface GroupCreate {
   permissions: Permission[];
   permissions: Permission[];
   restrict_printers?: boolean;
   restrict_printers?: boolean;
   printer_ids?: number[];
   printer_ids?: number[];
+  locations?: string[];
 }
 }
 
 
 export interface GroupUpdate {
 export interface GroupUpdate {
@@ -4324,6 +4327,7 @@ export interface GroupUpdate {
   permissions?: Permission[];
   permissions?: Permission[];
   restrict_printers?: boolean;
   restrict_printers?: boolean;
   printer_ids?: number[];
   printer_ids?: number[];
+  locations?: string[];
 }
 }
 
 
 export interface PermissionInfo {
 export interface PermissionInfo {

+ 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>
+  );
+}

+ 62 - 4
frontend/src/i18n/locales/de.ts

@@ -3525,14 +3525,72 @@ export default {
       noResults: 'Keine Berechtigungen entsprechen Ihrer Suche',
       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.',
       websocketHint: 'Erforderlich für Live-Aktualisierungen. Ohne diese Berechtigung greift die Oberfläche auf regelmäßiges Abrufen zurück.',
       printerAccess: 'Druckerzugriff',
       printerAccess: 'Druckerzugriff',
-      restrictPrinters: 'Mitgliedern nur die ausgewählten Drucker zeigen',
-      restrictPrintersHint: 'Mitglieder sehen und steuern nur diese Drucker. Gruppen ohne diese Einstellung schränken die Drucker nicht ein, daher sieht ein Benutzer, der in keiner eingeschränkten Gruppe ist, alle.',
-      noPrintersSelected: 'Keine Drucker ausgewählt: Mitglieder dieser Gruppe sehen keinen Drucker.',
       adminsSeeAllPrinters: 'Administratoren sehen immer alle Drucker.',
       adminsSeeAllPrinters: 'Administratoren sehen immer alle Drucker.',
-      noPrintersConfigured: 'Es wurden noch keine Drucker hinzugefügt.',
+      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 management
   users: {
   users: {
     title: 'Benutzerverwaltung',
     title: 'Benutzerverwaltung',

+ 62 - 4
frontend/src/i18n/locales/en.ts

@@ -3555,14 +3555,72 @@ export default {
       noResults: 'No permissions match your search',
       noResults: 'No permissions match your search',
       websocketHint: 'Required for live updates. Without it, the interface falls back to periodic polling.',
       websocketHint: 'Required for live updates. Without it, the interface falls back to periodic polling.',
       printerAccess: 'Printer access',
       printerAccess: 'Printer access',
-      restrictPrinters: 'Only show members the selected printers',
-      restrictPrintersHint: 'Members can see and control only these printers. Groups without this setting don\'t limit printers, so a user who is in no limited group sees all of them.',
-      noPrintersSelected: 'No printers selected: members of this group won\'t see any printer.',
       adminsSeeAllPrinters: 'Administrators always see every printer.',
       adminsSeeAllPrinters: 'Administrators always see every printer.',
-      noPrintersConfigured: 'No printers have been added yet.',
+      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 management
   users: {
   users: {
     title: 'User Management',
     title: 'User Management',

+ 62 - 4
frontend/src/i18n/locales/es.ts

@@ -3527,14 +3527,72 @@ export default {
       noResults: 'Ningún permiso coincide con su búsqueda',
       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.',
       websocketHint: 'Necesario para las actualizaciones en vivo. Sin este permiso, la interfaz recurre al sondeo periódico.',
       printerAccess: 'Acceso a impresoras',
       printerAccess: 'Acceso a impresoras',
-      restrictPrinters: 'Mostrar a los miembros solo las impresoras seleccionadas',
-      restrictPrintersHint: 'Los miembros solo pueden ver y controlar estas impresoras. Los grupos sin este ajuste no limitan las impresoras, así que un usuario que no esté en ningún grupo limitado las ve todas.',
-      noPrintersSelected: 'No hay impresoras seleccionadas: los miembros de este grupo no verán ninguna impresora.',
       adminsSeeAllPrinters: 'Los administradores siempre ven todas las impresoras.',
       adminsSeeAllPrinters: 'Los administradores siempre ven todas las impresoras.',
-      noPrintersConfigured: 'Aún no se ha añadido ninguna impresora.',
+      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 management
   users: {
   users: {
     title: 'Gestión de usuarios',
     title: 'Gestión de usuarios',

+ 62 - 4
frontend/src/i18n/locales/fr.ts

@@ -3513,14 +3513,72 @@ export default {
       noResults: 'Aucune permission ne correspond à votre recherche',
       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.",
       websocketHint: "Requis pour les mises à jour en direct. Sans cette permission, l'interface bascule sur une actualisation périodique.",
       printerAccess: 'Accès aux imprimantes',
       printerAccess: 'Accès aux imprimantes',
-      restrictPrinters: 'Ne montrer aux membres que les imprimantes sélectionnées',
-      restrictPrintersHint: 'Les membres ne voient et ne contrôlent que ces imprimantes. Les groupes sans ce réglage ne limitent pas les imprimantes : un utilisateur qui n\'est dans aucun groupe limité les voit toutes.',
-      noPrintersSelected: 'Aucune imprimante sélectionnée : les membres de ce groupe ne verront aucune imprimante.',
       adminsSeeAllPrinters: 'Les administrateurs voient toujours toutes les imprimantes.',
       adminsSeeAllPrinters: 'Les administrateurs voient toujours toutes les imprimantes.',
-      noPrintersConfigured: 'Aucune imprimante n\'a encore été ajoutée.',
+      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 management
   users: {
   users: {
     title: 'Gestion des Utilisateurs',
     title: 'Gestion des Utilisateurs',

+ 62 - 4
frontend/src/i18n/locales/it.ts

@@ -3512,14 +3512,72 @@ export default {
       noResults: 'Nessun permesso corrisponde alla ricerca',
       noResults: 'Nessun permesso corrisponde alla ricerca',
       websocketHint: "Necessario per gli aggiornamenti in tempo reale. Senza questo permesso, l'interfaccia ripiega sul polling periodico.",
       websocketHint: "Necessario per gli aggiornamenti in tempo reale. Senza questo permesso, l'interfaccia ripiega sul polling periodico.",
       printerAccess: 'Accesso alle stampanti',
       printerAccess: 'Accesso alle stampanti',
-      restrictPrinters: 'Mostra ai membri solo le stampanti selezionate',
-      restrictPrintersHint: 'I membri vedono e controllano solo queste stampanti. I gruppi senza questa impostazione non limitano le stampanti, quindi un utente che non fa parte di alcun gruppo limitato le vede tutte.',
-      noPrintersSelected: 'Nessuna stampante selezionata: i membri di questo gruppo non vedranno alcuna stampante.',
       adminsSeeAllPrinters: 'Gli amministratori vedono sempre tutte le stampanti.',
       adminsSeeAllPrinters: 'Gli amministratori vedono sempre tutte le stampanti.',
-      noPrintersConfigured: 'Non è ancora stata aggiunta alcuna stampante.',
+      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 management
   users: {
   users: {
     title: 'Gestione utenti',
     title: 'Gestione utenti',

+ 62 - 4
frontend/src/i18n/locales/ja.ts

@@ -3525,14 +3525,72 @@ export default {
       noResults: '検索に一致する権限がありません',
       noResults: '検索に一致する権限がありません',
       websocketHint: 'ライブ更新に必要です。この権限がないと、インターフェースは定期的なポーリングに切り替わります。',
       websocketHint: 'ライブ更新に必要です。この権限がないと、インターフェースは定期的なポーリングに切り替わります。',
       printerAccess: 'プリンターへのアクセス',
       printerAccess: 'プリンターへのアクセス',
-      restrictPrinters: 'メンバーには選択したプリンターのみ表示する',
-      restrictPrintersHint: 'メンバーはこれらのプリンターのみ表示・操作できます。この設定がないグループはプリンターを制限しないため、制限付きグループに属していないユーザーはすべてのプリンターを表示できます。',
-      noPrintersSelected: 'プリンターが選択されていません。このグループのメンバーにはプリンターが表示されません。',
       adminsSeeAllPrinters: '管理者には常にすべてのプリンターが表示されます。',
       adminsSeeAllPrinters: '管理者には常にすべてのプリンターが表示されます。',
-      noPrintersConfigured: 'プリンターはまだ追加されていません。',
+      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 management
   users: {
   users: {
     title: 'ユーザー管理',
     title: 'ユーザー管理',

+ 63 - 4
frontend/src/i18n/locales/ko.ts

@@ -3353,13 +3353,72 @@ export default {
       noResults: '검색과 일치하는 권한이 없습니다',
       noResults: '검색과 일치하는 권한이 없습니다',
       websocketHint: '실시간 업데이트에 필요합니다. 이 권한이 없으면 인터페이스는 주기적 폴링으로 대체됩니다.',
       websocketHint: '실시간 업데이트에 필요합니다. 이 권한이 없으면 인터페이스는 주기적 폴링으로 대체됩니다.',
       printerAccess: '프린터 접근',
       printerAccess: '프린터 접근',
-      restrictPrinters: '구성원에게 선택한 프린터만 표시',
-      restrictPrintersHint: '구성원은 이 프린터만 보고 제어할 수 있습니다. 이 설정이 없는 그룹은 프린터를 제한하지 않으므로, 제한된 그룹에 속하지 않은 사용자는 모든 프린터를 볼 수 있습니다.',
-      noPrintersSelected: '선택된 프린터가 없습니다. 이 그룹의 구성원에게는 프린터가 표시되지 않습니다.',
       adminsSeeAllPrinters: '관리자는 항상 모든 프린터를 볼 수 있습니다.',
       adminsSeeAllPrinters: '관리자는 항상 모든 프린터를 볼 수 있습니다.',
-      noPrintersConfigured: '아직 추가된 프린터가 없습니다.',
+      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: {
   users: {
     title: '사용자 관리',
     title: '사용자 관리',
     subtitle: 'Bambuddy 인스턴스에 대한 사용자 및 접근 권한 관리',
     subtitle: 'Bambuddy 인스턴스에 대한 사용자 및 접근 권한 관리',

+ 62 - 4
frontend/src/i18n/locales/nl.ts

@@ -3555,14 +3555,72 @@ export default {
       noResults: 'Geen machtigingen komen overeen met je zoekopdracht',
       noResults: 'Geen machtigingen komen overeen met je zoekopdracht',
       websocketHint: 'Vereist voor live-updates. Zonder dit valt de interface terug op periodieke polling.',
       websocketHint: 'Vereist voor live-updates. Zonder dit valt de interface terug op periodieke polling.',
       printerAccess: 'Printertoegang',
       printerAccess: 'Printertoegang',
-      restrictPrinters: 'Leden alleen de geselecteerde printers tonen',
-      restrictPrintersHint: 'Leden zien en bedienen alleen deze printers. Groepen zonder deze instelling beperken printers niet, dus een gebruiker die in geen enkele beperkte groep zit, ziet ze allemaal.',
-      noPrintersSelected: 'Geen printers geselecteerd: leden van deze groep zien geen enkele printer.',
       adminsSeeAllPrinters: 'Beheerders zien altijd alle printers.',
       adminsSeeAllPrinters: 'Beheerders zien altijd alle printers.',
-      noPrintersConfigured: 'Er zijn nog geen printers toegevoegd.',
+      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 management
   users: {
   users: {
     title: 'Gebruikersbeheer',
     title: 'Gebruikersbeheer',

+ 62 - 4
frontend/src/i18n/locales/pt-BR.ts

@@ -3512,14 +3512,72 @@ export default {
       noResults: 'Nenhuma permissão corresponde à sua pesquisa',
       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.',
       websocketHint: 'Necessário para atualizações em tempo real. Sem esta permissão, a interface recorre à sondagem periódica.',
       printerAccess: 'Acesso às impressoras',
       printerAccess: 'Acesso às impressoras',
-      restrictPrinters: 'Mostrar aos membros apenas as impressoras selecionadas',
-      restrictPrintersHint: 'Os membros só podem ver e controlar estas impressoras. Grupos sem esta configuração não limitam as impressoras, então um usuário que não está em nenhum grupo limitado vê todas.',
-      noPrintersSelected: 'Nenhuma impressora selecionada: os membros deste grupo não verão nenhuma impressora.',
       adminsSeeAllPrinters: 'Administradores sempre veem todas as impressoras.',
       adminsSeeAllPrinters: 'Administradores sempre veem todas as impressoras.',
-      noPrintersConfigured: 'Nenhuma impressora foi adicionada ainda.',
+      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 management
   users: {
   users: {
     title: 'Gerenciamento de Usuários',
     title: 'Gerenciamento de Usuários',

+ 63 - 4
frontend/src/i18n/locales/ru.ts

@@ -3345,13 +3345,72 @@ export default {
       noResults: "Нет разрешений, соответствующих запросу",
       noResults: "Нет разрешений, соответствующих запросу",
       websocketHint: "Необходимо для обновлений в реальном времени. Без этого интерфейс будет периодически опрашивать сервер.",
       websocketHint: "Необходимо для обновлений в реальном времени. Без этого интерфейс будет периодически опрашивать сервер.",
       printerAccess: 'Доступ к принтерам',
       printerAccess: 'Доступ к принтерам',
-      restrictPrinters: 'Показывать участникам только выбранные принтеры',
-      restrictPrintersHint: 'Участники видят и управляют только этими принтерами. Группы без этой настройки не ограничивают принтеры, поэтому пользователь, не состоящий ни в одной ограниченной группе, видит все принтеры.',
-      noPrintersSelected: 'Принтеры не выбраны: участники этой группы не увидят ни одного принтера.',
       adminsSeeAllPrinters: 'Администраторы всегда видят все принтеры.',
       adminsSeeAllPrinters: 'Администраторы всегда видят все принтеры.',
-      noPrintersConfigured: 'Принтеры ещё не добавлены.',
+      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: {
   users: {
     title: "Управление пользователями",
     title: "Управление пользователями",
     subtitle: "Управление пользователями и их доступом к Bambuddy",
     subtitle: "Управление пользователями и их доступом к Bambuddy",

+ 62 - 4
frontend/src/i18n/locales/sv.ts

@@ -3554,14 +3554,72 @@ errors: {
       noResults: 'Inga behörigheter matchar din sökning',
       noResults: 'Inga behörigheter matchar din sökning',
       websocketHint: 'Krävs för liveuppdateringar. Utan det faller gränssnittet tillbaka på periodisk avfrågning.',
       websocketHint: 'Krävs för liveuppdateringar. Utan det faller gränssnittet tillbaka på periodisk avfrågning.',
       printerAccess: 'Skrivaråtkomst',
       printerAccess: 'Skrivaråtkomst',
-      restrictPrinters: 'Visa endast de valda skrivarna för medlemmar',
-      restrictPrintersHint: 'Medlemmar kan bara se och styra dessa skrivare. Grupper utan den här inställningen begränsar inte skrivare, så en användare som inte är med i någon begränsad grupp ser alla.',
-      noPrintersSelected: 'Inga skrivare valda: medlemmar i den här gruppen ser ingen skrivare.',
       adminsSeeAllPrinters: 'Administratörer ser alltid alla skrivare.',
       adminsSeeAllPrinters: 'Administratörer ser alltid alla skrivare.',
-      noPrintersConfigured: 'Inga skrivare har lagts till än.',
+      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 management
   users: {
   users: {
     title: 'Användarhantering',
     title: 'Användarhantering',

+ 62 - 4
frontend/src/i18n/locales/tr.ts

@@ -3527,14 +3527,72 @@ export default {
       noResults: 'Aramanızla eşleşen izin yok',
       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.',
       websocketHint: 'Canlı güncellemeler için gereklidir. Bu izin olmadan arayüz düzenli yoklamaya geri döner.',
       printerAccess: 'Yazıcı erişimi',
       printerAccess: 'Yazıcı erişimi',
-      restrictPrinters: 'Üyelere yalnızca seçilen yazıcıları göster',
-      restrictPrintersHint: 'Üyeler yalnızca bu yazıcıları görebilir ve kontrol edebilir. Bu ayarı olmayan gruplar yazıcıları sınırlamaz; bu nedenle hiçbir sınırlı grupta olmayan bir kullanıcı tüm yazıcıları görür.',
-      noPrintersSelected: 'Hiç yazıcı seçilmedi: bu grubun üyeleri hiçbir yazıcıyı görmeyecek.',
       adminsSeeAllPrinters: 'Yöneticiler her zaman tüm yazıcıları görür.',
       adminsSeeAllPrinters: 'Yöneticiler her zaman tüm yazıcıları görür.',
-      noPrintersConfigured: 'Henüz yazıcı eklenmedi.',
+      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
   // Kullanıcı yönetimi
   users: {
   users: {
     title: 'Kullanıcı Yönetimi',
     title: 'Kullanıcı Yönetimi',

+ 62 - 4
frontend/src/i18n/locales/uk.ts

@@ -3552,14 +3552,72 @@ export default {
       noResults: "Немає дозволів, що відповідають вашому пошуку",
       noResults: "Немає дозволів, що відповідають вашому пошуку",
       websocketHint: "Необхідний для оновлень у реальному часі. Без нього інтерфейс повертається до періодичного опитування.",
       websocketHint: "Необхідний для оновлень у реальному часі. Без нього інтерфейс повертається до періодичного опитування.",
       printerAccess: 'Доступ до принтерів',
       printerAccess: 'Доступ до принтерів',
-      restrictPrinters: 'Показувати учасникам лише вибрані принтери',
-      restrictPrintersHint: 'Учасники бачать і керують лише цими принтерами. Групи без цього налаштування не обмежують принтери, тож користувач, який не входить до жодної обмеженої групи, бачить усі принтери.',
-      noPrintersSelected: 'Принтери не вибрано: учасники цієї групи не бачитимуть жодного принтера.',
       adminsSeeAllPrinters: 'Адміністратори завжди бачать усі принтери.',
       adminsSeeAllPrinters: 'Адміністратори завжди бачать усі принтери.',
-      noPrintersConfigured: 'Принтери ще не додано.',
+      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 management
   users: {
   users: {
     title: "Керування користувачами",
     title: "Керування користувачами",

+ 62 - 4
frontend/src/i18n/locales/zh-CN.ts

@@ -3512,14 +3512,72 @@ export default {
       noResults: '没有权限匹配您的搜索',
       noResults: '没有权限匹配您的搜索',
       websocketHint: '实时更新所需。缺少此权限时,界面将回退到定期轮询。',
       websocketHint: '实时更新所需。缺少此权限时,界面将回退到定期轮询。',
       printerAccess: '打印机访问',
       printerAccess: '打印机访问',
-      restrictPrinters: '仅向成员显示所选打印机',
-      restrictPrintersHint: '成员只能查看和控制这些打印机。未启用此设置的组不会限制打印机,因此不属于任何受限组的用户可以看到所有打印机。',
-      noPrintersSelected: '未选择打印机:该组成员将看不到任何打印机。',
       adminsSeeAllPrinters: '管理员始终可以看到所有打印机。',
       adminsSeeAllPrinters: '管理员始终可以看到所有打印机。',
-      noPrintersConfigured: '尚未添加打印机。',
+      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 management
   users: {
   users: {
     title: '用户管理',
     title: '用户管理',

+ 62 - 4
frontend/src/i18n/locales/zh-TW.ts

@@ -3512,14 +3512,72 @@ export default {
       noResults: '沒有權限匹配您的搜尋',
       noResults: '沒有權限匹配您的搜尋',
       websocketHint: '即時更新所需。缺少此權限時,介面將回退到定期輪詢。',
       websocketHint: '即時更新所需。缺少此權限時,介面將回退到定期輪詢。',
       printerAccess: '印表機存取',
       printerAccess: '印表機存取',
-      restrictPrinters: '僅向成員顯示所選印表機',
-      restrictPrintersHint: '成員只能檢視和控制這些印表機。未啟用此設定的群組不會限制印表機,因此不屬於任何受限群組的使用者可以看到所有印表機。',
-      noPrintersSelected: '未選擇印表機:此群組的成員將看不到任何印表機。',
       adminsSeeAllPrinters: '管理員一律可以看到所有印表機。',
       adminsSeeAllPrinters: '管理員一律可以看到所有印表機。',
-      noPrintersConfigured: '尚未新增印表機。',
+      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 management
   users: {
   users: {
     title: '使用者管理',
     title: '使用者管理',

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

@@ -23,7 +23,7 @@ export type SettingsSearchTab =
   | 'backup'
   | 'backup'
   | 'failure-detection';
   | '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;
 export type UsersSubTab = SettingsSearchSubTab;
 
 

+ 25 - 82
frontend/src/pages/GroupEditPage.tsx

@@ -1,5 +1,5 @@
 import { useState, useMemo } from 'react';
 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { ArrowLeft, Save, Loader2, Search, Check, Minus, Shield, AlertTriangle, Printer as PrinterIcon } from 'lucide-react';
 import { ArrowLeft, Save, Loader2, Search, Check, Minus, Shield, AlertTriangle, Printer as PrinterIcon } from 'lucide-react';
@@ -7,7 +7,6 @@ import { api } from '../api/client';
 import type { GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client';
 import type { GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { Card } from '../components/Card';
 import { Card } from '../components/Card';
-import { Toggle } from '../components/Toggle';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 
 
 export function GroupEditPage() {
 export function GroupEditPage() {
@@ -21,8 +20,6 @@ export function GroupEditPage() {
   const [name, setName] = useState('');
   const [name, setName] = useState('');
   const [description, setDescription] = useState('');
   const [description, setDescription] = useState('');
   const [permissions, setPermissions] = useState<Permission[]>([]);
   const [permissions, setPermissions] = useState<Permission[]>([]);
-  const [restrictPrinters, setRestrictPrinters] = useState(false);
-  const [printerIds, setPrinterIds] = useState<number[]>([]);
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
   const [initialized, setInitialized] = useState(false);
   const [initialized, setInitialized] = useState(false);
 
 
@@ -37,18 +34,11 @@ export function GroupEditPage() {
     queryFn: () => api.getPermissions(),
     queryFn: () => api.getPermissions(),
   });
   });
 
 
-  const { data: printers } = useQuery({
-    queryKey: ['printers'],
-    queryFn: () => api.getPrinters(),
-  });
-
   // Initialize form from fetched group data (once)
   // Initialize form from fetched group data (once)
   if (isEditing && groupData && !initialized) {
   if (isEditing && groupData && !initialized) {
     setName(groupData.name);
     setName(groupData.name);
     setDescription(groupData.description || '');
     setDescription(groupData.description || '');
     setPermissions(groupData.permissions);
     setPermissions(groupData.permissions);
-    setRestrictPrinters(groupData.restrict_printers ?? false);
-    setPrinterIds(groupData.printer_ids ?? []);
     setInitialized(true);
     setInitialized(true);
   }
   }
 
 
@@ -89,7 +79,7 @@ export function GroupEditPage() {
   });
   });
 
 
   const isSaving = createMutation.isPending || updateMutation.isPending;
   const isSaving = createMutation.isPending || updateMutation.isPending;
-  // Administrators see every printer regardless; the backend refuses to restrict them
+  // Administrators see every printer regardless
   const isAdministrators = isEditing && groupData?.is_system === true && groupData.name === 'Administrators';
   const isAdministrators = isEditing && groupData?.is_system === true && groupData.name === 'Administrators';
 
 
   const handleSave = () => {
   const handleSave = () => {
@@ -99,8 +89,7 @@ export function GroupEditPage() {
     }
     }
     if (isEditing) {
     if (isEditing) {
       // System groups refuse any permissions payload, so only send it when it
       // System groups refuse any permissions payload, so only send it when it
-      // changed -- otherwise their description and printer access couldn't be
-      // saved at all.
+      // changed -- otherwise their description couldn't be saved at all.
       const permissionsChanged =
       const permissionsChanged =
         !groupData ||
         !groupData ||
         permissions.length !== groupData.permissions.length ||
         permissions.length !== groupData.permissions.length ||
@@ -109,26 +98,16 @@ export function GroupEditPage() {
         name: name !== groupData?.name ? name : undefined,
         name: name !== groupData?.name ? name : undefined,
         description,
         description,
         permissions: groupData?.is_system && !permissionsChanged ? undefined : permissions,
         permissions: groupData?.is_system && !permissionsChanged ? undefined : permissions,
-        restrict_printers: isAdministrators ? undefined : restrictPrinters,
-        printer_ids: isAdministrators ? undefined : printerIds,
       });
       });
     } else {
     } else {
       createMutation.mutate({
       createMutation.mutate({
         name,
         name,
         description: description || undefined,
         description: description || undefined,
         permissions,
         permissions,
-        restrict_printers: restrictPrinters,
-        printer_ids: printerIds,
       });
       });
     }
     }
   };
   };
 
 
-  const togglePrinter = (printerId: number) => {
-    setPrinterIds((prev) =>
-      prev.includes(printerId) ? prev.filter((p) => p !== printerId) : [...prev, printerId]
-    );
-  };
-
   const togglePermission = (perm: Permission) => {
   const togglePermission = (perm: Permission) => {
     setPermissions((prev) =>
     setPermissions((prev) =>
       prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
       prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
@@ -234,68 +213,32 @@ export function GroupEditPage() {
         </div>
         </div>
       </div>
       </div>
 
 
-      {/* Printer access (#1727) */}
+      {/* Printer access (#1727) is managed on its own page */}
       <Card>
       <Card>
-        <div className="p-4 space-y-3">
-          <div className="flex items-center gap-2">
+        <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" />
             <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-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>
           </div>
-          {isAdministrators ? (
-            <p className="text-sm text-bambu-gray">{t('groups.editor.adminsSeeAllPrinters')}</p>
-          ) : (
-            <>
-              <div className="flex items-start justify-between gap-4">
-                <div>
-                  <p className="text-sm text-white">{t('groups.editor.restrictPrinters')}</p>
-                  <p className="text-xs text-bambu-gray mt-1">{t('groups.editor.restrictPrintersHint')}</p>
-                </div>
-                <Toggle checked={restrictPrinters} onChange={setRestrictPrinters} />
-              </div>
-              {restrictPrinters && (
-                <div className="space-y-2">
-                  {!printers || printers.length === 0 ? (
-                    <p className="text-sm text-bambu-gray">{t('groups.editor.noPrintersConfigured')}</p>
-                  ) : (
-                    <>
-                      <div className="flex items-center gap-3">
-                        <span className="text-sm text-bambu-gray">
-                          {t('groups.editor.permissionsSelected', { count: printerIds.length })} / {printers.length}
-                        </span>
-                        <Button size="sm" variant="ghost" onClick={() => setPrinterIds(printers.map((p) => p.id))}>
-                          {t('groups.editor.selectAll')}
-                        </Button>
-                        <Button size="sm" variant="ghost" onClick={() => setPrinterIds([])}>
-                          {t('groups.editor.clearAll')}
-                        </Button>
-                      </div>
-                      <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-1">
-                        {printers.map((printer) => (
-                          <label
-                            key={printer.id}
-                            className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer"
-                          >
-                            <input
-                              type="checkbox"
-                              checked={printerIds.includes(printer.id)}
-                              onChange={() => togglePrinter(printer.id)}
-                              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"
-                            />
-                            <span className="text-sm text-bambu-gray truncate">{printer.name}</span>
-                          </label>
-                        ))}
-                      </div>
-                    </>
-                  )}
-                  {printerIds.length === 0 && (
-                    <div className="flex items-center gap-2 text-xs text-yellow-700 dark:text-yellow-400">
-                      <AlertTriangle className="w-3.5 h-3.5 shrink-0" />
-                      {t('groups.editor.noPrintersSelected')}
-                    </div>
-                  )}
-                </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>
         </div>
       </Card>
       </Card>

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

@@ -157,6 +157,7 @@ import {
   PictureInPicture2,
   PictureInPicture2,
   ThumbsUp,
   ThumbsUp,
   ThumbsDown,
   ThumbsDown,
+  KeyRound,
 } from 'lucide-react';
 } from 'lucide-react';
 import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
 import { ConfirmOutcomeDialog } from '../components/ConfirmOutcomeDialog';
 
 
@@ -2113,7 +2114,7 @@ function PrinterCard({
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const navigate = useNavigate();
   const navigate = useNavigate();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { hasPermission, canModify } = useAuth();
+  const { hasPermission, canModify, authEnabled, isAdmin } = useAuth();
   const [showMenu, setShowMenu] = useState(false);
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [deleteArchives, setDeleteArchives] = useState(true);
   const [deleteArchives, setDeleteArchives] = useState(true);
@@ -3743,6 +3744,18 @@ function PrinterCard({
             <Info className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
             <Info className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
             {t('printers.printerInformation')}
             {t('printers.printerInformation')}
           </button>
           </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 */}
           {/* Maintenance Mode toggle (#1476) — leverages backend is_active flag */}
           <button
           <button
             className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
             className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
@@ -8444,6 +8457,7 @@ function EditPrinterModal({
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { authEnabled, isAdmin } = useAuth();
   const [form, setForm] = useState({
   const [form, setForm] = useState({
     name: printer.name,
     name: printer.name,
     ip_address: printer.ip_address,
     ip_address: printer.ip_address,
@@ -8454,6 +8468,25 @@ function EditPrinterModal({
     is_active: printer.is_active,
     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
   // 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.
   // edit that breaks connectivity (e.g. a mistyped IP) is caught before save.
   const [checkingSave, setCheckingSave] = useState(false);
   const [checkingSave, setCheckingSave] = useState(false);
@@ -8483,7 +8516,8 @@ function EditPrinterModal({
       name: form.name,
       name: form.name,
       ip_address: form.ip_address,
       ip_address: form.ip_address,
       model: form.model || undefined,
       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,
       auto_archive: form.auto_archive,
       is_active: form.is_active,
       is_active: form.is_active,
     };
     };
@@ -8613,6 +8647,12 @@ function EditPrinterModal({
                 placeholder={t('printers.modal.locationPlaceholder')}
                 placeholder={t('printers.modal.locationPlaceholder')}
               />
               />
               <p className="text-xs text-bambu-gray mt-1">{t('printers.locationHelp')}</p>
               <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>
             <div className="flex items-center gap-2">
             <div className="flex items-center gap-2">
               <input
               <input

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

@@ -50,6 +50,7 @@ import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
 import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
 import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
+import { PrinterAccessSettings } from '../components/PrinterAccessSettings';
 import { TwoFactorSettings } from '../components/TwoFactorSettings';
 import { TwoFactorSettings } from '../components/TwoFactorSettings';
 import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
 import { OIDCProviderSettings } from '../components/OIDCProviderSettings';
 import { SecurityStatusCard } from '../components/SecurityStatusCard';
 import { SecurityStatusCard } from '../components/SecurityStatusCard';
@@ -111,6 +112,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.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.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: '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.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.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' });
 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' });
@@ -257,7 +259,22 @@ export function SettingsPage() {
   const isLegacyEmailTab = tabParam === 'email';
   const isLegacyEmailTab = tabParam === 'email';
   const initialTab = isLegacyEmailTab ? 'users' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
   const initialTab = isLegacyEmailTab ? 'users' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
   const [activeTab, setActiveTab] = useState<TabType>(initialTab);
   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,
   // Workflow tab sub-tabs (#1425): 'dispatch' = current Workflow content,
   // 'pipelines' = Slicer Pipelines management. URL: ?tab=queue&sub=pipelines.
   // 'pipelines' = Slicer Pipelines management. URL: ?tab=queue&sub=pipelines.
   const initialQueueSub: 'dispatch' | 'pipelines' =
   const initialQueueSub: 'dispatch' | 'pipelines' =
@@ -270,9 +287,10 @@ export function SettingsPage() {
     if (tab === 'users') {
     if (tab === 'users') {
       setUsersSubTab('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') {
     if (tab === 'queue') {
       setQueueSubTab('dispatch');
       setQueueSubTab('dispatch');
-      searchParams.delete('sub');
     }
     }
     if (tab === 'general') {
     if (tab === 'general') {
       searchParams.delete('tab');
       searchParams.delete('tab');
@@ -1509,7 +1527,7 @@ export function SettingsPage() {
   const jumpToSetting = (entry: typeof searchIndex[number]) => {
   const jumpToSetting = (entry: typeof searchIndex[number]) => {
     handleTabChange(entry.tab as TabType);
     handleTabChange(entry.tab as TabType);
     if (entry.subTab) {
     if (entry.subTab) {
-      setUsersSubTab(entry.subTab as UsersSubTab);
+      selectUsersSubTab(entry.subTab as UsersSubTab);
     }
     }
     setSettingsSearch('');
     setSettingsSearch('');
     // Scroll to the card after the tab has rendered
     // Scroll to the card after the tab has rendered
@@ -6613,9 +6631,9 @@ export function SettingsPage() {
           {/* Sub-tab Navigation */}
           {/* Sub-tab Navigation */}
           <div className="flex gap-1 border-b border-bambu-dark-tertiary">
           <div className="flex gap-1 border-b border-bambu-dark-tertiary">
             <button
             <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 ${
               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-green border-bambu-green'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
               }`}
               }`}
@@ -6623,10 +6641,23 @@ export function SettingsPage() {
               <Users className="w-4 h-4" />
               <Users className="w-4 h-4" />
               {t('settings.tabs.users')}
               {t('settings.tabs.users')}
             </button>
             </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
             <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 ${
               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-green border-bambu-green'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
               }`}
               }`}
@@ -6638,9 +6669,9 @@ export function SettingsPage() {
               )}
               )}
             </button>
             </button>
             <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 ${
               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-green border-bambu-green'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
               }`}
               }`}
@@ -6652,9 +6683,9 @@ export function SettingsPage() {
               )}
               )}
             </button>
             </button>
             <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 ${
               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-green border-bambu-green'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
                   : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
               }`}
               }`}
@@ -6671,9 +6702,9 @@ export function SettingsPage() {
             </button>
             </button>
             {isAdmin && (
             {isAdmin && (
               <button
               <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 ${
                 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-green border-bambu-green'
                     : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
                     : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
                 }`}
                 }`}
@@ -6691,9 +6722,9 @@ export function SettingsPage() {
             )}
             )}
             {isAdmin && (
             {isAdmin && (
               <button
               <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 ${
                 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-green border-bambu-green'
                     : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
                     : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
                 }`}
                 }`}
@@ -6705,7 +6736,7 @@ export function SettingsPage() {
           </div>
           </div>
 
 
           {/* Users Sub-tab */}
           {/* Users Sub-tab */}
-          {usersSubTab === 'users' && (
+          {shownUsersSubTab === 'users' && (
           <>
           <>
           {/* Auth Toggle Header */}
           {/* Auth Toggle Header */}
           <Card>
           <Card>
@@ -7081,26 +7112,28 @@ export function SettingsPage() {
           </>
           </>
           )}
           )}
 
 
+          {shownUsersSubTab === 'printer-access' && <PrinterAccessSettings />}
+
           {/* Email Auth Sub-tab */}
           {/* Email Auth Sub-tab */}
-          {usersSubTab === 'email' && (
+          {shownUsersSubTab === 'email' && (
             <div className="max-w-5xl" id="card-smtp">
             <div className="max-w-5xl" id="card-smtp">
               <EmailSettings />
               <EmailSettings />
             </div>
             </div>
           )}
           )}
 
 
-          {usersSubTab === 'ldap' && (
+          {shownUsersSubTab === 'ldap' && (
             <div className="max-w-5xl" id="card-ldap">
             <div className="max-w-5xl" id="card-ldap">
               <LDAPSettings />
               <LDAPSettings />
             </div>
             </div>
           )}
           )}
 
 
-          {usersSubTab === 'twofa' && (
+          {shownUsersSubTab === 'twofa' && (
             <div className="max-w-2xl">
             <div className="max-w-2xl">
               <TwoFactorSettings />
               <TwoFactorSettings />
             </div>
             </div>
           )}
           )}
 
 
-          {usersSubTab === 'oidc' && isAdmin && (
+          {shownUsersSubTab === 'oidc' && isAdmin && (
             <div className="max-w-3xl space-y-4">
             <div className="max-w-3xl space-y-4">
               <Card>
               <Card>
                 <CardContent className="space-y-3 p-4">
                 <CardContent className="space-y-3 p-4">
@@ -7122,7 +7155,7 @@ export function SettingsPage() {
             </div>
             </div>
           )}
           )}
 
 
-          {usersSubTab === 'security' && isAdmin && (
+          {shownUsersSubTab === 'security' && isAdmin && (
             <div className="max-w-3xl">
             <div className="max-w-3xl">
               <SecurityStatusCard />
               <SecurityStatusCard />
             </div>
             </div>

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


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


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


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


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


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


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


+ 2 - 2
static/index.html

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

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