Преглед на файлове

Merge branch '0.1.6-final' into feature/192

MartinNYHC преди 7 месеца
родител
ревизия
be7b3f99a2
променени са 87 файла, в които са добавени 11572 реда и са изтрити 2151 реда
  1. 35 18
      CHANGELOG.md
  2. 9 4
      README.md
  3. 112 3
      backend/app/api/routes/archives.py
  4. 45 18
      backend/app/api/routes/auth.py
  5. 316 0
      backend/app/api/routes/groups.py
  6. 129 0
      backend/app/api/routes/library.py
  7. 91 18
      backend/app/api/routes/printers.py
  8. 123 1
      backend/app/api/routes/settings.py
  9. 286 15
      backend/app/api/routes/smart_plugs.py
  10. 138 55
      backend/app/api/routes/users.py
  11. 125 2
      backend/app/core/auth.py
  12. 223 23
      backend/app/core/database.py
  13. 392 0
      backend/app/core/permissions.py
  14. 36 1
      backend/app/main.py
  15. 3 0
      backend/app/models/__init__.py
  16. 54 0
      backend/app/models/group.py
  17. 31 2
      backend/app/models/smart_plug.py
  18. 82 3
      backend/app/models/user.py
  19. 21 1
      backend/app/schemas/auth.py
  20. 89 0
      backend/app/schemas/group.py
  21. 29 0
      backend/app/schemas/library.py
  22. 58 6
      backend/app/schemas/smart_plug.py
  23. 1 1
      backend/app/services/homeassistant.py
  24. 32 1
      backend/app/services/mqtt_relay.py
  25. 488 0
      backend/app/services/mqtt_smart_plug.py
  26. 8 4
      backend/app/services/print_scheduler.py
  27. 11 6
      backend/app/services/smart_plug_manager.py
  28. 140 0
      backend/app/services/stl_thumbnail.py
  29. 40 0
      backend/tests/conftest.py
  30. 124 0
      backend/tests/integration/test_archives_api.py
  31. 330 1
      backend/tests/integration/test_auth_api.py
  32. 236 0
      backend/tests/integration/test_library_api.py
  33. 252 0
      backend/tests/integration/test_smart_plugs_api.py
  34. 204 0
      backend/tests/unit/services/test_stl_thumbnail.py
  35. 0 4
      demo-video/.gitignore
  36. 0 50
      demo-video/README.md
  37. 0 537
      demo-video/package-lock.json
  38. 0 15
      demo-video/package.json
  39. 0 566
      demo-video/record-demo.ts
  40. 48 18
      deploy/bambuddy.service
  41. 4 4
      frontend/package-lock.json
  42. 1 1
      frontend/package.json
  43. 8 4
      frontend/src/App.tsx
  44. 5 1
      frontend/src/__tests__/components/EditArchiveModal.test.tsx
  45. 87 0
      frontend/src/__tests__/components/SmartPlugCard.test.tsx
  46. 300 0
      frontend/src/__tests__/components/TagManagementModal.test.tsx
  47. 169 0
      frontend/src/__tests__/contexts/AuthContext.test.tsx
  48. 84 0
      frontend/src/__tests__/mocks/handlers.ts
  49. 198 2
      frontend/src/__tests__/pages/FileManagerPage.test.tsx
  50. 136 0
      frontend/src/__tests__/pages/GroupsPage.test.tsx
  51. 9 4
      frontend/src/__tests__/pages/LoginPage.test.tsx
  52. 230 0
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  53. 232 14
      frontend/src/api/client.ts
  54. 275 66
      frontend/src/components/AddSmartPlugModal.tsx
  55. 2 0
      frontend/src/components/ContextMenu.tsx
  56. 33 14
      frontend/src/components/EditArchiveModal.tsx
  57. 27 12
      frontend/src/components/KProfilesView.tsx
  58. 216 38
      frontend/src/components/Layout.tsx
  59. 128 73
      frontend/src/components/SmartPlugCard.tsx
  60. 60 30
      frontend/src/components/SwitchbarPopover.tsx
  61. 294 0
      frontend/src/components/TagManagementModal.tsx
  62. 40 2
      frontend/src/contexts/AuthContext.tsx
  63. 109 12
      frontend/src/pages/ArchivesPage.tsx
  64. 371 115
      frontend/src/pages/FileManagerPage.tsx
  65. 496 0
      frontend/src/pages/GroupsPage.tsx
  66. 61 0
      frontend/src/pages/LoginPage.tsx
  67. 47 16
      frontend/src/pages/MaintenancePage.tsx
  68. 137 49
      frontend/src/pages/PrintersPage.tsx
  69. 46 8
      frontend/src/pages/ProfilesPage.tsx
  70. 51 14
      frontend/src/pages/ProjectDetailPage.tsx
  71. 41 10
      frontend/src/pages/ProjectsPage.tsx
  72. 28 9
      frontend/src/pages/QueuePage.tsx
  73. 1019 177
      frontend/src/pages/SettingsPage.tsx
  74. 6 2
      frontend/src/pages/StatsPage.tsx
  75. 308 0
      frontend/src/pages/StreamOverlayPage.tsx
  76. 310 101
      frontend/src/pages/UsersPage.tsx
  77. 234 0
      install/README.md
  78. 541 0
      install/docker-install.sh
  79. 883 0
      install/install.sh
  80. 5 0
      requirements.txt
  81. 0 0
      static/assets/index-B3Buyqqk.css
  82. 0 0
      static/assets/index-BJQC4Kk-.js
  83. 0 0
      static/assets/index-BTpjfCpx.css
  84. 0 0
      static/assets/index-Bwf4poPr.css
  85. 0 0
      static/assets/index-C1mIxrzF.css
  86. 0 0
      static/assets/index-DlQJz8pN.js
  87. 0 0
      static/assets/index-wxwVsx5u.js

+ 35 - 18
CHANGELOG.md

@@ -5,6 +5,41 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6-final] - Not released
 ## [0.1.6-final] - Not released
 
 
 ### New Features
 ### New Features
+- **Group-Based Permissions** - Granular access control with user groups:
+  - Create custom groups with specific permissions (50+ granular permissions)
+  - Default system groups: Administrators (full access), Operators (control printers), Viewers (read-only)
+  - Users can belong to multiple groups with additive permissions
+  - Permission-based UI: buttons/features disabled when user lacks permission
+  - Groups management page in Settings → Users → Groups tab
+  - Change password: users can change their own password from sidebar
+  - Included in backup/restore
+- **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):
+  - Checkbox option when uploading STL files to generate thumbnails automatically
+  - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button
+  - Individual file thumbnail generation via context menu (three-dot menu)
+  - Works with ZIP extraction (generates thumbnails for all STL files in archive)
+  - Uses trimesh and matplotlib for 3D rendering with Bambu green color theme
+  - Thumbnails auto-refresh in UI after generation
+  - Graceful handling of complex/invalid STL files
+- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164):
+  - All-in-one page at `/overlay/:printerId` combining camera feed with status overlay
+  - Real-time print progress, ETA, layer count, and filename display
+  - Bambuddy logo branding (links to GitHub)
+  - Customizable via query parameters: `?size=small|medium|large` and `?show=progress,layers,eta,filename,status,printer`
+  - No authentication required - designed for OBS browser source embedding
+  - Gradient overlay at bottom for readable text over camera feed
+  - Auto-reconnect on camera stream errors
+- **MQTT Smart Plug Support** - Add smart plugs that subscribe to MQTT topics for energy monitoring (Issue #173):
+  - New "MQTT" plug type alongside Tasmota and Home Assistant
+  - Subscribe to any MQTT topic (Zigbee2MQTT, Shelly, Tasmota discovery, etc.)
+  - **Separate topics per data type**: Configure different MQTT topics for power, energy, and state
+  - Configurable JSON paths for data extraction (e.g., `power_l1`, `data.power`)
+  - **Separate multipliers**: Individual multiplier for power and energy (e.g., mW→W, Wh→kWh)
+  - **Custom ON value**: Configure what value means "ON" for state (e.g., "ON", "true", "1")
+  - Monitor-only: displays power/energy data without control capabilities
+  - Reuses existing MQTT broker settings from Settings → Network
+  - Energy data included in statistics and per-print tracking
+  - Full backup/restore support for MQTT plug configurations
 - **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
 - **Disable Printer Firmware Checks** - New toggle in Settings → General → Updates to disable printer firmware update checks:
   - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
   - Prevents Bambuddy from checking Bambu Lab servers for firmware updates
   - Useful for users who prefer to manage firmware manually or have network restrictions
   - Useful for users who prefer to manage firmware manually or have network restrictions
@@ -90,24 +125,6 @@ All notable changes to Bambuddy will be documented in this file.
   - Bulk edit: printer assignment, print options, queue options
   - Bulk edit: printer assignment, print options, queue options
   - Bulk cancel selected items
   - Bulk cancel selected items
   - Tri-state toggles: unchanged / on / off for each setting
   - Tri-state toggles: unchanged / on / off for each setting
-- **Model-Based Queue Assignment** - Queue prints to "any printer of matching model" for load balancing (Issue #162):
-  - Extract printer model from sliced 3MF files (e.g., "X1C", "P1S")
-  - Display sliced-for model in archive view
-  - New queue mode: assign to model instead of specific printer
-  - Scheduler auto-assigns to first idle printer of matching model
-  - Filament validation: only assign to printers with required filament types loaded
-  - Waiting reason display: shows why jobs are waiting (e.g., "Waiting for filament: Printer1 (needs PLA)")
-  - "Waiting" status badge (purple) distinguishes from regular "Pending"
-  - Compatibility warnings when file/printer model mismatch
-- **Queue Notifications** - Get notified about print queue events:
-  - Job Added: When a job is added to the queue
-  - Job Assigned: When a model-based job is assigned to a printer
-  - Job Started: When a queue job starts printing
-  - Job Waiting: When a job is waiting for filament (enabled by default)
-  - Job Skipped: When a job is skipped due to previous failure (enabled by default)
-  - Job Failed: When a job fails to start (enabled by default)
-  - Queue Complete: When all queued jobs finish
-  - New "Print Queue" section in notification provider settings
 
 
 ### Fixes
 ### Fixes
 - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):
 - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166):

+ 9 - 4
README.md

@@ -54,10 +54,12 @@
 - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
 - Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 - Archive comparison (side-by-side diff)
+- Tag management (rename/delete across all archives)
 
 
 ### 📊 Monitoring & Control
 ### 📊 Monitoring & Control
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support
 - Live camera streaming (MJPEG) & snapshots with multi-viewer support
+- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`)
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment)
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Fan status monitoring (part cooling, auxiliary, chamber)
@@ -80,14 +82,16 @@
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Queue Only mode (stage without auto-start)
-- Smart plug integration (Tasmota, Home Assistant)
+- Smart plug integration (Tasmota, Home Assistant, MQTT)
+- MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring
 - Energy consumption tracking (per-print kWh and cost)
 - Energy consumption tracking (per-print kWh and cost)
 - HA energy sensor support (for plugs with separate power/energy sensors)
 - HA energy sensor support (for plugs with separate power/energy sensors)
 - Auto power-on before print
 - Auto power-on before print
 - Auto power-off after cooldown
 - Auto power-off after cooldown
 
 
 ### 📁 File Manager (Library)
 ### 📁 File Manager (Library)
-- Upload and organize sliced files (3MF, gcode)
+- Upload and organize sliced files (3MF, gcode, STL)
+- **STL thumbnail generation** - Auto-generate previews for STL files on upload or batch generate for existing files
 - ZIP file extraction with folder structure preservation
 - ZIP file extraction with folder structure preservation
 - Option to create folder from ZIP filename
 - Option to create folder from ZIP filename
 - Folder structure with drag-and-drop
 - Folder structure with drag-and-drop
@@ -151,9 +155,10 @@
 
 
 ### 🔒 Optional Authentication
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
 - Enable/disable authentication any time
-- Role-based access (Admin/User)
+- Group-based permissions (50+ granular permissions)
+- Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
 - JWT tokens with secure password hashing
-- User management (create, edit, delete)
+- User management (create, edit, delete, groups)
 
 
 </td>
 </td>
 </tr>
 </tr>

+ 112 - 3
backend/app/api/routes/archives.py

@@ -515,6 +515,8 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     if energy_tracking_mode == "total":
     if energy_tracking_mode == "total":
         # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
         # Total mode: sum up 'total' counter from all smart plugs (lifetime consumption)
         from backend.app.models.smart_plug import SmartPlug
         from backend.app.models.smart_plug import SmartPlug
+        from backend.app.services.homeassistant import homeassistant_service
+        from backend.app.services.mqtt_relay import mqtt_relay
         from backend.app.services.tasmota import tasmota_service
         from backend.app.services.tasmota import tasmota_service
 
 
         plugs_result = await db.execute(select(SmartPlug))
         plugs_result = await db.execute(select(SmartPlug))
@@ -522,9 +524,19 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
 
 
         total_energy_kwh = 0.0
         total_energy_kwh = 0.0
         for plug in plugs:
         for plug in plugs:
-            energy = await tasmota_service.get_energy(plug)
-            if energy and energy.get("total") is not None:
-                total_energy_kwh += energy["total"]
+            if plug.plug_type == "tasmota":
+                energy = await tasmota_service.get_energy(plug)
+                if energy and energy.get("total") is not None:
+                    total_energy_kwh += energy["total"]
+            elif plug.plug_type == "homeassistant":
+                energy = await homeassistant_service.get_energy(plug)
+                if energy and energy.get("total") is not None:
+                    total_energy_kwh += energy["total"]
+            elif plug.plug_type == "mqtt":
+                # MQTT plugs report "today" energy, not lifetime total
+                mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
+                if mqtt_data and mqtt_data.energy is not None:
+                    total_energy_kwh += mqtt_data.energy
 
 
         total_energy_kwh = round(total_energy_kwh, 3)
         total_energy_kwh = round(total_energy_kwh, 3)
         total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2)
         total_energy_cost = round(total_energy_kwh * energy_cost_per_kwh, 2)
@@ -552,6 +564,103 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     )
     )
 
 
 
 
+@router.get("/tags")
+async def get_all_tags(db: AsyncSession = Depends(get_db)):
+    """List all unique tags with usage counts.
+
+    Returns a list of tags sorted by count (descending), then by name.
+    """
+    # Query all archives with non-null tags
+    result = await db.execute(select(PrintArchive.tags).where(PrintArchive.tags.isnot(None)))
+    all_tags_rows = result.all()
+
+    # Count occurrences of each tag
+    tag_counts: dict[str, int] = {}
+    for (tags_str,) in all_tags_rows:
+        if tags_str:
+            for tag in tags_str.split(","):
+                tag = tag.strip()
+                if tag:
+                    tag_counts[tag] = tag_counts.get(tag, 0) + 1
+
+    # Convert to list and sort by count (desc), then name (asc)
+    tags_list = [{"name": name, "count": count} for name, count in tag_counts.items()]
+    tags_list.sort(key=lambda x: (-x["count"], x["name"].lower()))
+
+    return tags_list
+
+
+@router.put("/tags/{tag_name}")
+async def rename_tag(
+    tag_name: str,
+    request: Request,
+    db: AsyncSession = Depends(get_db),
+):
+    """Rename a tag across all archives.
+
+    Request body should contain {"new_name": "new tag name"}.
+    Returns the count of affected archives.
+    """
+    body = await request.json()
+    new_name = body.get("new_name", "").strip()
+
+    if not new_name:
+        raise HTTPException(400, "new_name is required")
+
+    if new_name == tag_name:
+        return {"affected": 0}
+
+    # Find all archives containing the old tag
+    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    archives = list(result.scalars().all())
+
+    affected = 0
+    for archive in archives:
+        if not archive.tags:
+            continue
+        tags = [t.strip() for t in archive.tags.split(",")]
+        if tag_name in tags:
+            # Replace old tag with new tag
+            new_tags = [new_name if t == tag_name else t for t in tags]
+            # Remove duplicates while preserving order
+            seen = set()
+            unique_tags = []
+            for t in new_tags:
+                if t not in seen:
+                    seen.add(t)
+                    unique_tags.append(t)
+            archive.tags = ", ".join(unique_tags)
+            affected += 1
+
+    await db.commit()
+    return {"affected": affected}
+
+
+@router.delete("/tags/{tag_name}")
+async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
+    """Delete a tag from all archives.
+
+    Returns the count of affected archives.
+    """
+    # Find all archives containing the tag
+    result = await db.execute(select(PrintArchive).where(PrintArchive.tags.isnot(None)))
+    archives = list(result.scalars().all())
+
+    affected = 0
+    for archive in archives:
+        if not archive.tags:
+            continue
+        tags = [t.strip() for t in archive.tags.split(",")]
+        if tag_name in tags:
+            # Remove the tag
+            new_tags = [t for t in tags if t != tag_name]
+            archive.tags = ", ".join(new_tags) if new_tags else None
+            affected += 1
+
+    await db.commit()
+    return {"affected": affected}
+
+
 @router.get("/{archive_id}", response_model=ArchiveResponse)
 @router.get("/{archive_id}", response_model=ArchiveResponse)
 async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
 async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific archive."""
     """Get a specific archive."""

+ 45 - 18
backend/app/api/routes/auth.py

@@ -3,6 +3,7 @@ from datetime import timedelta
 from fastapi import APIRouter, Depends, HTTPException, status
 from fastapi import APIRouter, Depends, HTTPException, status
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
     ACCESS_TOKEN_EXPIRE_MINUTES,
     ACCESS_TOKEN_EXPIRE_MINUTES,
@@ -13,9 +14,25 @@ from backend.app.core.auth import (
     get_user_by_username,
     get_user_by_username,
 )
 )
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.schemas.auth import LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
+from backend.app.schemas.auth import GroupBrief, LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
+
+
+def _user_to_response(user: User) -> UserResponse:
+    """Convert a User model to UserResponse schema."""
+    return UserResponse(
+        id=user.id,
+        username=user.username,
+        role=user.role,
+        is_active=user.is_active,
+        is_admin=user.is_admin,
+        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
+        permissions=sorted(user.get_permissions()),
+        created_at=user.created_at.isoformat(),
+    )
+
 
 
 router = APIRouter(prefix="/auth", tags=["authentication"])
 router = APIRouter(prefix="/auth", tags=["authentication"])
 
 
@@ -126,6 +143,14 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                         role="admin",
                         role="admin",
                         is_active=True,
                         is_active=True,
                     )
                     )
+
+                    # Try to add user to Administrators group if it exists
+                    admin_group_result = await db.execute(select(Group).where(Group.name == "Administrators"))
+                    admin_group = admin_group_result.scalar_one_or_none()
+                    if admin_group:
+                        admin_user.groups.append(admin_group)
+                        logger.info("Added new admin user to Administrators group")
+
                     db.add(admin_user)
                     db.add(admin_user)
                     logger.info(f"Admin user added to session: {request.admin_username}")
                     logger.info(f"Admin user added to session: {request.admin_username}")
                     admin_created = True
                     admin_created = True
@@ -179,8 +204,12 @@ async def disable_auth(
 
 
     logger = logging.getLogger(__name__)
     logger = logging.getLogger(__name__)
 
 
+    # Reload user with groups for proper is_admin check
+    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
     # Only admins can disable authentication
     # Only admins can disable authentication
-    if current_user.role != "admin":
+    if not user.is_admin:
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_403_FORBIDDEN,
             status_code=status.HTTP_403_FORBIDDEN,
             detail="Only admins can disable authentication",
             detail="Only admins can disable authentication",
@@ -189,7 +218,7 @@ async def disable_auth(
     try:
     try:
         await set_auth_enabled(db, False)
         await set_auth_enabled(db, False)
         await db.commit()
         await db.commit()
-        logger.info(f"Authentication disabled by admin user: {current_user.username}")
+        logger.info(f"Authentication disabled by admin user: {user.username}")
         return {"message": "Authentication disabled successfully", "auth_enabled": False}
         return {"message": "Authentication disabled successfully", "auth_enabled": False}
     except Exception as e:
     except Exception as e:
         await db.rollback()
         await db.rollback()
@@ -219,32 +248,30 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
             headers={"WWW-Authenticate": "Bearer"},
             headers={"WWW-Authenticate": "Bearer"},
         )
         )
 
 
+    # Reload user with groups for proper permission calculation
+    result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
     access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
     access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
 
 
     return LoginResponse(
     return LoginResponse(
         access_token=access_token,
         access_token=access_token,
         token_type="bearer",
         token_type="bearer",
-        user=UserResponse(
-            id=user.id,
-            username=user.username,
-            role=user.role,
-            is_active=user.is_active,
-            created_at=user.created_at.isoformat(),
-        ),
+        user=_user_to_response(user),
     )
     )
 
 
 
 
 @router.get("/me", response_model=UserResponse)
 @router.get("/me", response_model=UserResponse)
-async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
+async def get_current_user_info(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+):
     """Get current user information."""
     """Get current user information."""
-    return UserResponse(
-        id=current_user.id,
-        username=current_user.username,
-        role=current_user.role,
-        is_active=current_user.is_active,
-        created_at=current_user.created_at.isoformat(),
-    )
+    # Reload user with groups for proper permission calculation
+    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+    return _user_to_response(user)
 
 
 
 
 @router.post("/logout")
 @router.post("/logout")

+ 316 - 0
backend/app/api/routes/groups.py

@@ -0,0 +1,316 @@
+"""Group management API routes."""
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import (
+    ALL_PERMISSIONS,
+    PERMISSION_CATEGORIES,
+    Permission,
+)
+from backend.app.models.group import Group
+from backend.app.models.user import User
+from backend.app.schemas.group import (
+    GroupCreate,
+    GroupDetailResponse,
+    GroupResponse,
+    GroupUpdate,
+    PermissionCategory,
+    PermissionInfo,
+    PermissionsListResponse,
+    UserBrief,
+)
+
+router = APIRouter(prefix="/groups", tags=["groups"])
+
+
+def _permission_label(perm: Permission) -> str:
+    """Convert permission enum to human-readable label."""
+    # e.g., "printers:read" -> "Read Printers"
+    parts = perm.value.split(":")
+    if len(parts) == 2:
+        resource, action = parts
+        resource = resource.replace("_", " ").title()
+        action = action.title()
+        return f"{action} {resource}"
+    return perm.value
+
+
+@router.get("/permissions", response_model=PermissionsListResponse)
+async def list_permissions(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
+):
+    """List all available permissions organized by category."""
+    categories = []
+    for name, perms in PERMISSION_CATEGORIES.items():
+        categories.append(
+            PermissionCategory(
+                name=name,
+                permissions=[PermissionInfo(value=p.value, label=_permission_label(p)) for p in perms],
+            )
+        )
+    return PermissionsListResponse(
+        categories=categories,
+        all_permissions=ALL_PERMISSIONS,
+    )
+
+
+@router.get("", response_model=list[GroupResponse])
+@router.get("/", response_model=list[GroupResponse])
+async def list_groups(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """List all groups."""
+    result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
+    groups = result.scalars().all()
+    return [
+        GroupResponse(
+            id=group.id,
+            name=group.name,
+            description=group.description,
+            permissions=group.permissions or [],
+            is_system=group.is_system,
+            user_count=len(group.users),
+            created_at=group.created_at,
+            updated_at=group.updated_at,
+        )
+        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)
+async def create_group(
+    group_data: GroupCreate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new group."""
+    # Check if group name already exists
+    existing = await db.execute(select(Group).where(Group.name == group_data.name))
+    if existing.scalar_one_or_none():
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Group name already exists",
+        )
+
+    # Validate permissions
+    invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
+    if invalid_perms:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=f"Invalid permissions: {', '.join(invalid_perms)}",
+        )
+
+    group = Group(
+        name=group_data.name,
+        description=group_data.description,
+        permissions=group_data.permissions,
+        is_system=False,  # User-created groups are not system groups
+    )
+    db.add(group)
+    await db.commit()
+    await db.refresh(group)
+
+    return GroupResponse(
+        id=group.id,
+        name=group.name,
+        description=group.description,
+        permissions=group.permissions or [],
+        is_system=group.is_system,
+        user_count=0,
+        created_at=group.created_at,
+        updated_at=group.updated_at,
+    )
+
+
+@router.get("/{group_id}", response_model=GroupDetailResponse)
+async def get_group(
+    group_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a group by ID with user list."""
+    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
+    group = result.scalar_one_or_none()
+    if not group:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Group not found",
+        )
+
+    return GroupDetailResponse(
+        id=group.id,
+        name=group.name,
+        description=group.description,
+        permissions=group.permissions or [],
+        is_system=group.is_system,
+        user_count=len(group.users),
+        created_at=group.created_at,
+        updated_at=group.updated_at,
+        users=[UserBrief(id=u.id, username=u.username, is_active=u.is_active) for u in group.users],
+    )
+
+
+@router.patch("/{group_id}", response_model=GroupResponse)
+async def update_group(
+    group_id: int,
+    group_data: GroupUpdate,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a group."""
+    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
+    group = result.scalar_one_or_none()
+    if not group:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Group not found",
+        )
+
+    # Check if updating name to one that already exists
+    if group_data.name is not None and group_data.name != group.name:
+        existing = await db.execute(select(Group).where(Group.name == group_data.name, Group.id != group_id))
+        if existing.scalar_one_or_none():
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Group name already exists",
+            )
+        # System groups cannot have their name changed
+        if group.is_system:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Cannot rename system groups",
+            )
+        group.name = group_data.name
+
+    if group_data.description is not None:
+        group.description = group_data.description
+
+    if group_data.permissions is not None:
+        # Validate permissions
+        invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
+        if invalid_perms:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=f"Invalid permissions: {', '.join(invalid_perms)}",
+            )
+        group.permissions = group_data.permissions
+
+    await db.commit()
+    await db.refresh(group)
+
+    return GroupResponse(
+        id=group.id,
+        name=group.name,
+        description=group.description,
+        permissions=group.permissions or [],
+        is_system=group.is_system,
+        user_count=len(group.users),
+        created_at=group.created_at,
+        updated_at=group.updated_at,
+    )
+
+
+@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_group(
+    group_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a group (non-system groups only)."""
+    result = await db.execute(select(Group).where(Group.id == group_id))
+    group = result.scalar_one_or_none()
+    if not group:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Group not found",
+        )
+
+    if group.is_system:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Cannot delete system groups",
+        )
+
+    await db.delete(group)
+    await db.commit()
+
+
+@router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def add_user_to_group(
+    group_id: int,
+    user_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Add a user to a group."""
+    # Get group with users
+    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
+    group = result.scalar_one_or_none()
+    if not group:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Group not found",
+        )
+
+    # Get user
+    user_result = await db.execute(select(User).where(User.id == user_id))
+    user = user_result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    # Check if user is already in group
+    if user in group.users:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="User is already in this group",
+        )
+
+    group.users.append(user)
+    await db.commit()
+
+
+@router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def remove_user_from_group(
+    group_id: int,
+    user_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
+    db: AsyncSession = Depends(get_db),
+):
+    """Remove a user from a group."""
+    # Get group with users
+    result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
+    group = result.scalar_one_or_none()
+    if not group:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="Group not found",
+        )
+
+    # Get user
+    user_result = await db.execute(select(User).where(User.id == user_id))
+    user = user_result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    # Check if user is in group
+    if user not in group.users:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="User is not in this group",
+        )
+
+    group.users.remove(user)
+    await db.commit()

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

@@ -25,6 +25,9 @@ from backend.app.schemas.library import (
     AddToQueueRequest,
     AddToQueueRequest,
     AddToQueueResponse,
     AddToQueueResponse,
     AddToQueueResult,
     AddToQueueResult,
+    BatchThumbnailRequest,
+    BatchThumbnailResponse,
+    BatchThumbnailResult,
     BulkDeleteRequest,
     BulkDeleteRequest,
     BulkDeleteResponse,
     BulkDeleteResponse,
     FileDuplicate,
     FileDuplicate,
@@ -43,6 +46,7 @@ from backend.app.schemas.library import (
     ZipExtractResult,
     ZipExtractResult,
 )
 )
 from backend.app.services.archive import ArchiveService, ThreeMFParser
 from backend.app.services.archive import ArchiveService, ThreeMFParser
+from backend.app.services.stl_thumbnail import generate_stl_thumbnail
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -621,6 +625,7 @@ async def list_files(
 async def upload_file(
 async def upload_file(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     folder_id: int | None = None,
     folder_id: int | None = None,
+    generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Upload a file to the library."""
     """Upload a file to the library."""
@@ -712,6 +717,11 @@ async def upload_file(
             # For image files, create a thumbnail from the image itself
             # For image files, create a thumbnail from the image itself
             thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
             thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
 
+        elif ext == ".stl":
+            # Generate STL thumbnail if enabled
+            if generate_stl_thumbnails:
+                thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+
         # Create database entry
         # Create database entry
         library_file = LibraryFile(
         library_file = LibraryFile(
             folder_id=folder_id,
             folder_id=folder_id,
@@ -749,6 +759,7 @@ async def extract_zip_file(
     folder_id: int | None = Query(default=None),
     folder_id: int | None = Query(default=None),
     preserve_structure: bool = Query(default=True),
     preserve_structure: bool = Query(default=True),
     create_folder_from_zip: bool = Query(default=False),
     create_folder_from_zip: bool = Query(default=False),
+    generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Upload and extract a ZIP file to the library.
     """Upload and extract a ZIP file to the library.
@@ -758,6 +769,7 @@ async def extract_zip_file(
         folder_id: Target folder ID (None = root)
         folder_id: Target folder ID (None = root)
         preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat
         preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat
         create_folder_from_zip: If True, create a folder named after the ZIP file and extract into it
         create_folder_from_zip: If True, create a folder named after the ZIP file and extract into it
+        generate_stl_thumbnails: If True, generate thumbnails for STL files
     """
     """
     import tempfile
     import tempfile
     import zipfile
     import zipfile
@@ -941,6 +953,11 @@ async def extract_zip_file(
                     elif ext.lower() in IMAGE_EXTENSIONS:
                     elif ext.lower() in IMAGE_EXTENSIONS:
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
                         thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
 
 
+                    elif ext == ".stl":
+                        # Generate STL thumbnail if enabled
+                        if generate_stl_thumbnails:
+                            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+
                     # Create database entry
                     # Create database entry
                     library_file = LibraryFile(
                     library_file = LibraryFile(
                         folder_id=target_folder_id,
                         folder_id=target_folder_id,
@@ -994,6 +1011,118 @@ async def extract_zip_file(
             pass
             pass
 
 
 
 
+# ============ STL Thumbnail Batch Generation ============
+
+
+@router.post("/generate-stl-thumbnails", response_model=BatchThumbnailResponse)
+async def batch_generate_stl_thumbnails(
+    request: BatchThumbnailRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Generate thumbnails for STL files in batch.
+
+    Can generate thumbnails for:
+    - Specific file IDs (file_ids)
+    - All STL files in a folder (folder_id)
+    - All STL files missing thumbnails (all_missing=True)
+    """
+    thumbnails_dir = get_library_thumbnails_dir()
+    results: list[BatchThumbnailResult] = []
+
+    # Build query based on request
+    query = select(LibraryFile).where(LibraryFile.file_type == "stl")
+
+    if request.file_ids:
+        # Specific files
+        query = query.where(LibraryFile.id.in_(request.file_ids))
+    elif request.folder_id is not None:
+        # All STL files in a specific folder
+        query = query.where(LibraryFile.folder_id == request.folder_id)
+        if not request.all_missing:
+            # If not specifically asking for missing thumbnails, get all
+            pass
+        else:
+            query = query.where(LibraryFile.thumbnail_path.is_(None))
+    elif request.all_missing:
+        # All STL files without thumbnails
+        query = query.where(LibraryFile.thumbnail_path.is_(None))
+    else:
+        # No criteria specified - return empty
+        return BatchThumbnailResponse(
+            processed=0,
+            succeeded=0,
+            failed=0,
+            results=[],
+        )
+
+    result = await db.execute(query)
+    stl_files = result.scalars().all()
+
+    succeeded = 0
+    failed = 0
+
+    for stl_file in stl_files:
+        file_path = Path(stl_file.file_path)
+
+        if not file_path.exists():
+            results.append(
+                BatchThumbnailResult(
+                    file_id=stl_file.id,
+                    filename=stl_file.filename,
+                    success=False,
+                    error="File not found on disk",
+                )
+            )
+            failed += 1
+            continue
+
+        try:
+            thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir)
+
+            if thumbnail_path:
+                # Update database
+                stl_file.thumbnail_path = thumbnail_path
+                await db.flush()
+                results.append(
+                    BatchThumbnailResult(
+                        file_id=stl_file.id,
+                        filename=stl_file.filename,
+                        success=True,
+                    )
+                )
+                succeeded += 1
+            else:
+                results.append(
+                    BatchThumbnailResult(
+                        file_id=stl_file.id,
+                        filename=stl_file.filename,
+                        success=False,
+                        error="Thumbnail generation failed",
+                    )
+                )
+                failed += 1
+        except Exception as e:
+            logger.error(f"Failed to generate thumbnail for {stl_file.filename}: {e}")
+            results.append(
+                BatchThumbnailResult(
+                    file_id=stl_file.id,
+                    filename=stl_file.filename,
+                    success=False,
+                    error=str(e),
+                )
+            )
+            failed += 1
+
+    await db.commit()
+
+    return BatchThumbnailResponse(
+        processed=len(stl_files),
+        succeeded=succeeded,
+        failed=failed,
+        results=results,
+    )
+
+
 # ============ Queue Operations ============
 # ============ Queue Operations ============
 # NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts
 # NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts
 
 

+ 91 - 18
backend/app/api/routes/printers.py

@@ -8,9 +8,10 @@ from fastapi.responses import Response
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequireAdminIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 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.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
 from backend.app.schemas.printer import (
 from backend.app.schemas.printer import (
@@ -38,7 +39,10 @@ router = APIRouter(prefix="/printers", tags=["printers"])
 
 
 
 
 @router.get("/", response_model=list[PrinterResponse])
 @router.get("/", response_model=list[PrinterResponse])
-async def list_printers(db: AsyncSession = Depends(get_db)):
+async def list_printers(
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
     """List all configured printers."""
     """List all configured printers."""
     result = await db.execute(select(Printer).order_by(Printer.name))
     result = await db.execute(select(Printer).order_by(Printer.name))
     return list(result.scalars().all())
     return list(result.scalars().all())
@@ -47,8 +51,8 @@ async def list_printers(db: AsyncSession = Depends(get_db)):
 @router.post("/", response_model=PrinterResponse)
 @router.post("/", response_model=PrinterResponse)
 async def create_printer(
 async def create_printer(
     printer_data: PrinterCreate,
     printer_data: PrinterCreate,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _current_user=RequireAdminIfAuthEnabled(),
 ):
 ):
     """Add a new printer."""
     """Add a new printer."""
     # Check if serial number already exists
     # Check if serial number already exists
@@ -69,7 +73,9 @@ async def create_printer(
 
 
 
 
 @router.get("/usb-cameras")
 @router.get("/usb-cameras")
-async def list_usb_cameras():
+async def list_usb_cameras(
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+):
     """List available USB cameras connected to the system.
     """List available USB cameras connected to the system.
 
 
     Returns a list of detected V4L2 video devices with their info.
     Returns a list of detected V4L2 video devices with their info.
@@ -85,7 +91,11 @@ async def list_usb_cameras():
 
 
 
 
 @router.get("/{printer_id}", response_model=PrinterResponse)
 @router.get("/{printer_id}", response_model=PrinterResponse)
-async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def get_printer(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
     """Get a specific printer."""
     """Get a specific printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -98,8 +108,8 @@ async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
 async def update_printer(
 async def update_printer(
     printer_id: int,
     printer_id: int,
     printer_data: PrinterUpdate,
     printer_data: PrinterUpdate,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _current_user=RequireAdminIfAuthEnabled(),
 ):
 ):
     """Update a printer."""
     """Update a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -143,8 +153,8 @@ async def update_printer(
 async def delete_printer(
 async def delete_printer(
     printer_id: int,
     printer_id: int,
     delete_archives: bool = True,
     delete_archives: bool = True,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _current_user=RequireAdminIfAuthEnabled(),
 ):
 ):
     """Delete a printer.
     """Delete a printer.
 
 
@@ -191,7 +201,11 @@ async def delete_printer(
 
 
 
 
 @router.get("/{printer_id}/status", response_model=PrinterStatus)
 @router.get("/{printer_id}/status", response_model=PrinterStatus)
-async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def get_printer_status(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
     """Get real-time status of a printer."""
     """Get real-time status of a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -431,7 +445,11 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
 
 
 
 
 @router.post("/{printer_id}/refresh-status")
 @router.post("/{printer_id}/refresh-status")
-async def refresh_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def refresh_printer_status(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
     """Request a full status refresh from the printer (sends pushall command)."""
     """Request a full status refresh from the printer (sends pushall command)."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -446,7 +464,11 @@ async def refresh_printer_status(printer_id: int, db: AsyncSession = Depends(get
 
 
 
 
 @router.post("/{printer_id}/connect")
 @router.post("/{printer_id}/connect")
-async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def connect_printer(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Manually connect to a printer."""
     """Manually connect to a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -458,7 +480,11 @@ async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.post("/{printer_id}/disconnect")
 @router.post("/{printer_id}/disconnect")
-async def disconnect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def disconnect_printer(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Manually disconnect from a printer."""
     """Manually disconnect from a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -474,6 +500,7 @@ async def test_printer_connection(
     ip_address: str,
     ip_address: str,
     serial_number: str,
     serial_number: str,
     access_code: str,
     access_code: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
 ):
 ):
     """Test connection to a printer without saving."""
     """Test connection to a printer without saving."""
     result = await printer_manager.test_connection(
     result = await printer_manager.test_connection(
@@ -492,6 +519,7 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 async def get_printer_cover(
 async def get_printer_cover(
     printer_id: int,
     printer_id: int,
     view: str | None = None,
     view: str | None = None,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get the cover image for the current print job.
     """Get the cover image for the current print job.
@@ -679,6 +707,7 @@ async def get_printer_cover(
 async def list_printer_files(
 async def list_printer_files(
     printer_id: int,
     printer_id: int,
     path: str = "/",
     path: str = "/",
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """List files on the printer at the specified path."""
     """List files on the printer at the specified path."""
@@ -703,6 +732,7 @@ async def list_printer_files(
 async def download_printer_file(
 async def download_printer_file(
     printer_id: int,
     printer_id: int,
     path: str,
     path: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Download a file from the printer."""
     """Download a file from the printer."""
@@ -743,6 +773,7 @@ async def download_printer_file(
 async def download_printer_files_as_zip(
 async def download_printer_files_as_zip(
     printer_id: int,
     printer_id: int,
     request: dict,
     request: dict,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Download multiple files from the printer as a ZIP archive."""
     """Download multiple files from the printer as a ZIP archive."""
@@ -787,6 +818,7 @@ async def download_printer_files_as_zip(
 async def delete_printer_file(
 async def delete_printer_file(
     printer_id: int,
     printer_id: int,
     path: str,
     path: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete a file from the printer."""
     """Delete a file from the printer."""
@@ -805,6 +837,7 @@ async def delete_printer_file(
 @router.get("/{printer_id}/storage")
 @router.get("/{printer_id}/storage")
 async def get_printer_storage(
 async def get_printer_storage(
     printer_id: int,
     printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get storage information from the printer."""
     """Get storage information from the printer."""
@@ -824,7 +857,11 @@ async def get_printer_storage(
 
 
 
 
 @router.post("/{printer_id}/logging/enable")
 @router.post("/{printer_id}/logging/enable")
-async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def enable_mqtt_logging(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Enable MQTT message logging for a printer."""
     """Enable MQTT message logging for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -839,7 +876,11 @@ async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db
 
 
 
 
 @router.post("/{printer_id}/logging/disable")
 @router.post("/{printer_id}/logging/disable")
-async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def disable_mqtt_logging(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Disable MQTT message logging for a printer."""
     """Disable MQTT message logging for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -854,7 +895,11 @@ async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_d
 
 
 
 
 @router.get("/{printer_id}/logging")
 @router.get("/{printer_id}/logging")
-async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def get_mqtt_logs(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
     """Get MQTT message logs for a printer."""
     """Get MQTT message logs for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -877,7 +922,11 @@ async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.delete("/{printer_id}/logging")
 @router.delete("/{printer_id}/logging")
-async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def clear_mqtt_logs(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Clear MQTT message logs for a printer."""
     """Clear MQTT message logs for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -900,6 +949,7 @@ async def set_print_option(
     enabled: bool,
     enabled: bool,
     print_halt: bool = True,
     print_halt: bool = True,
     sensitivity: str = "medium",
     sensitivity: str = "medium",
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Set an AI detection / print option on the printer.
     """Set an AI detection / print option on the printer.
@@ -972,6 +1022,7 @@ async def start_calibration(
     motor_noise: bool = False,
     motor_noise: bool = False,
     nozzle_offset: bool = False,
     nozzle_offset: bool = False,
     high_temp_heatbed: bool = False,
     high_temp_heatbed: bool = False,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Start printer calibration with selected options.
     """Start printer calibration with selected options.
@@ -1027,6 +1078,7 @@ async def start_calibration(
 @router.get("/{printer_id}/slot-presets")
 @router.get("/{printer_id}/slot-presets")
 async def get_slot_presets(
 async def get_slot_presets(
     printer_id: int,
     printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get all saved slot-to-preset mappings for a printer."""
     """Get all saved slot-to-preset mappings for a printer."""
@@ -1049,6 +1101,7 @@ async def get_slot_preset(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     tray_id: int,
     tray_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get the saved preset for a specific slot."""
     """Get the saved preset for a specific slot."""
@@ -1079,6 +1132,7 @@ async def save_slot_preset(
     tray_id: int,
     tray_id: int,
     preset_id: str,
     preset_id: str,
     preset_name: str,
     preset_name: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Save a preset mapping for a specific slot."""
     """Save a preset mapping for a specific slot."""
@@ -1128,6 +1182,7 @@ async def delete_slot_preset(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     tray_id: int,
     tray_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete a saved preset mapping for a slot."""
     """Delete a saved preset mapping for a slot."""
@@ -1164,6 +1219,7 @@ async def configure_ams_slot(
     kprofile_filament_id: str = Query(""),
     kprofile_filament_id: str = Query(""),
     kprofile_setting_id: str = Query(""),
     kprofile_setting_id: str = Query(""),
     k_value: float = Query(0.0),
     k_value: float = Query(0.0),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
 ):
     """Configure an AMS slot with a specific filament setting and K profile.
     """Configure an AMS slot with a specific filament setting and K profile.
 
 
@@ -1363,7 +1419,11 @@ async def debug_simulate_print_complete(
 
 
 
 
 @router.post("/{printer_id}/print/stop")
 @router.post("/{printer_id}/print/stop")
-async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def stop_print(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Stop/cancel the current print job."""
     """Stop/cancel the current print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -1382,7 +1442,11 @@ async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.post("/{printer_id}/print/pause")
 @router.post("/{printer_id}/print/pause")
-async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def pause_print(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Pause the current print job."""
     """Pause the current print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -1401,7 +1465,11 @@ async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)):
 
 
 
 
 @router.post("/{printer_id}/print/resume")
 @router.post("/{printer_id}/print/resume")
-async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def resume_print(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
     """Resume a paused print job."""
     """Resume a paused print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -1423,6 +1491,7 @@ async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
 async def set_chamber_light(
 async def set_chamber_light(
     printer_id: int,
     printer_id: int,
     on: bool = Query(..., description="True to turn on, False to turn off"),
     on: bool = Query(..., description="True to turn on, False to turn off"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Turn the chamber light on or off."""
     """Turn the chamber light on or off."""
@@ -1446,6 +1515,7 @@ async def set_chamber_light(
 async def get_printable_objects(
 async def get_printable_objects(
     printer_id: int,
     printer_id: int,
     reload: bool = False,
     reload: bool = False,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get the list of printable objects for the current print.
     """Get the list of printable objects for the current print.
@@ -1543,6 +1613,7 @@ async def get_printable_objects(
 async def skip_objects(
 async def skip_objects(
     printer_id: int,
     printer_id: int,
     object_ids: list[int],
     object_ids: list[int],
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Skip specific objects during the current print.
     """Skip specific objects during the current print.
@@ -1597,6 +1668,7 @@ async def refresh_ams_slot(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     slot_id: int,
     slot_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Re-read RFID for an AMS slot (triggers filament info refresh)."""
     """Re-read RFID for an AMS slot (triggers filament info refresh)."""
@@ -1619,6 +1691,7 @@ async def refresh_ams_slot(
 @router.get("/{printer_id}/runtime-debug")
 @router.get("/{printer_id}/runtime-debug")
 async def get_runtime_debug(
 async def get_runtime_debug(
     printer_id: int,
     printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Debug endpoint: Get runtime tracking status for a printer."""
     """Debug endpoint: Get runtime tracking status for a printer."""

+ 123 - 1
backend/app/api/routes/settings.py

@@ -16,6 +16,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.external_link import ExternalLink
 from backend.app.models.external_link import ExternalLink
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig
 from backend.app.models.github_backup import GitHubBackupConfig
+from backend.app.models.group import Group
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
@@ -259,6 +260,7 @@ async def export_backup(
     include_users: bool = Query(
     include_users: bool = Query(
         False, description="Include users (passwords not exported - users will need new passwords)"
         False, description="Include users (passwords not exported - users will need new passwords)"
     ),
     ),
+    include_groups: bool = Query(False, description="Include groups and user-group assignments"),
     include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
     include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
 ):
 ):
     """Export selected data as JSON backup."""
     """Export selected data as JSON backup."""
@@ -367,6 +369,21 @@ async def export_backup(
                     "ha_power_entity": plug.ha_power_entity,
                     "ha_power_entity": plug.ha_power_entity,
                     "ha_energy_today_entity": plug.ha_energy_today_entity,
                     "ha_energy_today_entity": plug.ha_energy_today_entity,
                     "ha_energy_total_entity": plug.ha_energy_total_entity,
                     "ha_energy_total_entity": plug.ha_energy_total_entity,
+                    # MQTT plug fields (legacy)
+                    "mqtt_topic": plug.mqtt_topic,
+                    "mqtt_multiplier": plug.mqtt_multiplier,
+                    # MQTT power fields
+                    "mqtt_power_topic": plug.mqtt_power_topic,
+                    "mqtt_power_path": plug.mqtt_power_path,
+                    "mqtt_power_multiplier": plug.mqtt_power_multiplier,
+                    # MQTT energy fields
+                    "mqtt_energy_topic": plug.mqtt_energy_topic,
+                    "mqtt_energy_path": plug.mqtt_energy_path,
+                    "mqtt_energy_multiplier": plug.mqtt_energy_multiplier,
+                    # MQTT state fields
+                    "mqtt_state_topic": plug.mqtt_state_topic,
+                    "mqtt_state_path": plug.mqtt_state_path,
+                    "mqtt_state_on_value": plug.mqtt_state_on_value,
                     "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
                     "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
                     "enabled": plug.enabled,
                     "enabled": plug.enabled,
                     "auto_on": plug.auto_on,
                     "auto_on": plug.auto_on,
@@ -383,6 +400,7 @@ async def export_backup(
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_on_time": plug.schedule_on_time,
                     "schedule_off_time": plug.schedule_off_time,
                     "schedule_off_time": plug.schedule_off_time,
                     "show_in_switchbar": plug.show_in_switchbar,
                     "show_in_switchbar": plug.show_in_switchbar,
+                    "show_on_printer_card": plug.show_on_printer_card,
                 }
                 }
             )
             )
         backup["included"].append("smart_plugs")
         backup["included"].append("smart_plugs")
@@ -860,11 +878,28 @@ async def export_backup(
                     "username": user.username,
                     "username": user.username,
                     "role": user.role,
                     "role": user.role,
                     "is_active": user.is_active,
                     "is_active": user.is_active,
+                    "groups": [g.name for g in user.groups],
                     # password_hash intentionally not exported for security
                     # password_hash intentionally not exported for security
                 }
                 }
             )
             )
         backup["included"].append("users")
         backup["included"].append("users")
 
 
+    # Groups (permission groups)
+    if include_groups:
+        result = await db.execute(select(Group))
+        groups = result.scalars().all()
+        backup["groups"] = []
+        for group in groups:
+            backup["groups"].append(
+                {
+                    "name": group.name,
+                    "description": group.description,
+                    "permissions": group.permissions,
+                    "is_system": group.is_system,
+                }
+            )
+        backup["included"].append("groups")
+
     # GitHub backup configuration
     # GitHub backup configuration
     if include_github_backup:
     if include_github_backup:
         result = await db.execute(select(GitHubBackupConfig).limit(1))
         result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -982,6 +1017,7 @@ async def import_backup(
         "projects": 0,
         "projects": 0,
         "pending_uploads": 0,
         "pending_uploads": 0,
         "users": 0,
         "users": 0,
+        "groups": 0,
         "github_backup": 0,
         "github_backup": 0,
     }
     }
     skipped = {
     skipped = {
@@ -997,6 +1033,7 @@ async def import_backup(
         "projects": 0,
         "projects": 0,
         "pending_uploads": 0,
         "pending_uploads": 0,
         "users": 0,
         "users": 0,
+        "groups": 0,
         "github_backup": 0,
         "github_backup": 0,
     }
     }
     skipped_details = {
     skipped_details = {
@@ -1010,6 +1047,7 @@ async def import_backup(
         "projects": [],
         "projects": [],
         "pending_uploads": [],
         "pending_uploads": [],
         "users": [],
         "users": [],
+        "groups": [],
     }
     }
 
 
     # Restore settings (always overwrites)
     # Restore settings (always overwrites)
@@ -1278,12 +1316,23 @@ async def import_backup(
             # Determine plug type (default to tasmota for backwards compatibility)
             # Determine plug type (default to tasmota for backwards compatibility)
             plug_type = plug_data.get("plug_type", "tasmota")
             plug_type = plug_data.get("plug_type", "tasmota")
 
 
-            # Find existing plug by IP (Tasmota) or entity_id (Home Assistant)
+            # Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT)
             existing = None
             existing = None
+            plug_identifier = None
             if plug_type == "homeassistant" and plug_data.get("ha_entity_id"):
             if plug_type == "homeassistant" and plug_data.get("ha_entity_id"):
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"]))
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"]))
                 existing = result.scalar_one_or_none()
                 existing = result.scalar_one_or_none()
                 plug_identifier = plug_data["ha_entity_id"]
                 plug_identifier = plug_data["ha_entity_id"]
+            elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")):
+                # Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy)
+                power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")
+                result = await db.execute(
+                    select(SmartPlug).where(
+                        (SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic)
+                    )
+                )
+                existing = result.scalar_one_or_none()
+                plug_identifier = power_topic
             elif plug_data.get("ip_address"):
             elif plug_data.get("ip_address"):
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
                 result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"]))
                 existing = result.scalar_one_or_none()
                 existing = result.scalar_one_or_none()
@@ -1300,6 +1349,21 @@ async def import_backup(
                     existing.ha_power_entity = plug_data.get("ha_power_entity")
                     existing.ha_power_entity = plug_data.get("ha_power_entity")
                     existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity")
                     existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity")
                     existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity")
                     existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity")
+                    # MQTT fields (legacy)
+                    existing.mqtt_topic = plug_data.get("mqtt_topic")
+                    existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0)
+                    # MQTT power fields
+                    existing.mqtt_power_topic = plug_data.get("mqtt_power_topic")
+                    existing.mqtt_power_path = plug_data.get("mqtt_power_path")
+                    existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0)
+                    # MQTT energy fields
+                    existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic")
+                    existing.mqtt_energy_path = plug_data.get("mqtt_energy_path")
+                    existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0)
+                    # MQTT state fields
+                    existing.mqtt_state_topic = plug_data.get("mqtt_state_topic")
+                    existing.mqtt_state_path = plug_data.get("mqtt_state_path")
+                    existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value")
                     existing.printer_id = printer_id
                     existing.printer_id = printer_id
                     existing.enabled = plug_data.get("enabled", True)
                     existing.enabled = plug_data.get("enabled", True)
                     existing.auto_on = plug_data.get("auto_on", True)
                     existing.auto_on = plug_data.get("auto_on", True)
@@ -1316,6 +1380,7 @@ async def import_backup(
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_on_time = plug_data.get("schedule_on_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
                     existing.schedule_off_time = plug_data.get("schedule_off_time")
                     existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
                     existing.show_in_switchbar = plug_data.get("show_in_switchbar", False)
+                    existing.show_on_printer_card = plug_data.get("show_on_printer_card", True)
                     restored["smart_plugs"] += 1
                     restored["smart_plugs"] += 1
                 else:
                 else:
                     skipped["smart_plugs"] += 1
                     skipped["smart_plugs"] += 1
@@ -1329,6 +1394,21 @@ async def import_backup(
                     ha_power_entity=plug_data.get("ha_power_entity"),
                     ha_power_entity=plug_data.get("ha_power_entity"),
                     ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
                     ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
                     ha_energy_total_entity=plug_data.get("ha_energy_total_entity"),
                     ha_energy_total_entity=plug_data.get("ha_energy_total_entity"),
+                    # MQTT fields (legacy)
+                    mqtt_topic=plug_data.get("mqtt_topic"),
+                    mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0),
+                    # MQTT power fields
+                    mqtt_power_topic=plug_data.get("mqtt_power_topic"),
+                    mqtt_power_path=plug_data.get("mqtt_power_path"),
+                    mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0),
+                    # MQTT energy fields
+                    mqtt_energy_topic=plug_data.get("mqtt_energy_topic"),
+                    mqtt_energy_path=plug_data.get("mqtt_energy_path"),
+                    mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0),
+                    # MQTT state fields
+                    mqtt_state_topic=plug_data.get("mqtt_state_topic"),
+                    mqtt_state_path=plug_data.get("mqtt_state_path"),
+                    mqtt_state_on_value=plug_data.get("mqtt_state_on_value"),
                     printer_id=printer_id,
                     printer_id=printer_id,
                     enabled=plug_data.get("enabled", True),
                     enabled=plug_data.get("enabled", True),
                     auto_on=plug_data.get("auto_on", True),
                     auto_on=plug_data.get("auto_on", True),
@@ -1345,6 +1425,7 @@ async def import_backup(
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_on_time=plug_data.get("schedule_on_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     schedule_off_time=plug_data.get("schedule_off_time"),
                     show_in_switchbar=plug_data.get("show_in_switchbar", False),
                     show_in_switchbar=plug_data.get("show_in_switchbar", False),
+                    show_on_printer_card=plug_data.get("show_on_printer_card", True),
                 )
                 )
                 db.add(plug)
                 db.add(plug)
                 restored["smart_plugs"] += 1
                 restored["smart_plugs"] += 1
@@ -1977,6 +2058,39 @@ async def import_backup(
                     }
                     }
                 )
                 )
 
 
+    # Restore groups (before users, so groups exist for assignment)
+    if "groups" in backup:
+        for group_data in backup["groups"]:
+            result = await db.execute(select(Group).where(Group.name == group_data["name"]))
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite and not existing.is_system:
+                    # Update non-system groups
+                    existing.description = group_data.get("description")
+                    existing.permissions = group_data.get("permissions", [])
+                    restored["groups"] += 1
+                else:
+                    skipped["groups"] += 1
+                    skipped_details["groups"].append(group_data["name"])
+            else:
+                group = Group(
+                    name=group_data["name"],
+                    description=group_data.get("description"),
+                    permissions=group_data.get("permissions", []),
+                    is_system=group_data.get("is_system", False),
+                )
+                db.add(group)
+                restored["groups"] += 1
+
+    # Flush to ensure groups are persisted before user assignment
+    await db.flush()
+
+    # Build group name to object lookup for user assignment
+    group_name_to_obj: dict[str, Group] = {}
+    result = await db.execute(select(Group))
+    for g in result.scalars().all():
+        group_name_to_obj[g.name] = g
+
     # Restore users (note: passwords not included in backup - users will need new passwords)
     # Restore users (note: passwords not included in backup - users will need new passwords)
     # Users are skipped by default since they have no passwords; admin must recreate them
     # Users are skipped by default since they have no passwords; admin must recreate them
     new_users: list[str] = []
     new_users: list[str] = []
@@ -1990,6 +2104,10 @@ async def import_backup(
                 if overwrite:
                 if overwrite:
                     existing.role = user_data.get("role", "user")
                     existing.role = user_data.get("role", "user")
                     existing.is_active = user_data.get("is_active", True)
                     existing.is_active = user_data.get("is_active", True)
+                    # Assign groups if provided
+                    group_names = user_data.get("groups", [])
+                    if group_names:
+                        existing.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj]
                     # Don't change password - keep existing
                     # Don't change password - keep existing
                     restored["users"] += 1
                     restored["users"] += 1
                 else:
                 else:
@@ -2007,6 +2125,10 @@ async def import_backup(
                     role=user_data.get("role", "user"),
                     role=user_data.get("role", "user"),
                     is_active=user_data.get("is_active", True),
                     is_active=user_data.get("is_active", True),
                 )
                 )
+                # Assign groups if provided
+                group_names = user_data.get("groups", [])
+                if group_names:
+                    user.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj]
                 db.add(user)
                 db.add(user)
                 restored["users"] += 1
                 restored["users"] += 1
                 new_users.append(f"{user_data['username']} (temp password: {temp_password})")
                 new_users.append(f"{user_data['username']} (temp password: {temp_password})")

+ 286 - 15
backend/app/api/routes/smart_plugs.py

@@ -27,6 +27,7 @@ from backend.app.schemas.smart_plug import (
 )
 )
 from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.discovery import tasmota_scanner
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.homeassistant import homeassistant_service
+from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.tasmota import tasmota_service
 from backend.app.services.tasmota import tasmota_service
@@ -56,16 +57,84 @@ async def create_smart_plug(
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
         # Check if printer already has a plug assigned
         # Check if printer already has a plug assigned
-        result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
-        if result.scalar_one_or_none():
-            raise HTTPException(400, "This printer already has a smart plug assigned")
+        # Scripts can coexist with other plugs (they're for multi-device control, not power on/off)
+        is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.")
+        if not is_script:
+            # For non-script plugs, check there's no other non-script plug assigned
+            result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Allow if existing plug is a script
+                existing_is_script = (
+                    existing.plug_type == "homeassistant"
+                    and existing.ha_entity_id
+                    and existing.ha_entity_id.startswith("script.")
+                )
+                if not existing_is_script:
+                    raise HTTPException(400, "This printer already has a smart plug assigned")
+
+    # For MQTT plugs, ensure MQTT broker is configured and service is connected
+    if data.plug_type == "mqtt":
+        # Try to configure the smart plug service if not already configured
+        if not mqtt_relay.smart_plug_service.is_configured():
+            # Get MQTT broker settings from database
+            mqtt_broker = await get_setting(db, "mqtt_broker") or ""
+            if not mqtt_broker:
+                raise HTTPException(
+                    400,
+                    "MQTT broker not configured. Please set MQTT broker address in Settings → Network → MQTT Publishing.",
+                )
+
+            # Configure the smart plug service with broker settings
+            mqtt_settings = {
+                "mqtt_enabled": True,  # Enable for smart plug subscription
+                "mqtt_broker": mqtt_broker,
+                "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"),
+                "mqtt_username": await get_setting(db, "mqtt_username") or "",
+                "mqtt_password": await get_setting(db, "mqtt_password") or "",
+                "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true",
+            }
+            await mqtt_relay.smart_plug_service.configure(mqtt_settings)
+
+            # Check if connection succeeded
+            if not mqtt_relay.smart_plug_service.is_configured():
+                raise HTTPException(
+                    400,
+                    f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.",
+                )
 
 
     plug = SmartPlug(**data.model_dump())
     plug = SmartPlug(**data.model_dump())
     db.add(plug)
     db.add(plug)
     await db.commit()
     await db.commit()
     await db.refresh(plug)
     await db.refresh(plug)
 
 
-    if plug.plug_type == "homeassistant":
+    # Subscribe MQTT plugs to their topics
+    if plug.plug_type == "mqtt":
+        # Determine effective topics (new fields take priority, fall back to legacy)
+        power_topic = plug.mqtt_power_topic or plug.mqtt_topic
+        energy_topic = plug.mqtt_energy_topic
+        state_topic = plug.mqtt_state_topic
+
+        # Only subscribe if at least one topic is configured
+        if power_topic or energy_topic or state_topic:
+            mqtt_relay.smart_plug_service.subscribe(
+                plug_id=plug.id,
+                # Power source (path is optional)
+                power_topic=power_topic,
+                power_path=plug.mqtt_power_path,
+                power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
+                # Energy source (path is optional)
+                energy_topic=energy_topic,
+                energy_path=plug.mqtt_energy_path,
+                energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
+                # State source (path is optional)
+                state_topic=state_topic,
+                state_path=plug.mqtt_state_path,
+                state_on_value=plug.mqtt_state_on_value,
+            )
+            topics = [t for t in [power_topic, energy_topic, state_topic] if t]
+            logger.info(f"Created MQTT plug '{plug.name}' subscribed to {', '.join(set(topics))}")
+    elif plug.plug_type == "homeassistant":
         logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
         logger.info(f"Created Home Assistant plug '{plug.name}' ({plug.ha_entity_id})")
     else:
     else:
         logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}")
         logger.info(f"Created Tasmota plug '{plug.name}' at {plug.ip_address}")
@@ -74,12 +143,48 @@ async def create_smart_plug(
 
 
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
 async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
-    """Get the smart plug assigned to a printer."""
+    """Get the main smart plug assigned to a printer.
+
+    When multiple plugs are assigned (e.g., a regular plug + script),
+    returns the main (non-script) plug for power control.
+    """
     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
-    plug = result.scalar_one_or_none()
-    if not plug:
+    plugs = result.scalars().all()
+
+    if not plugs:
         return None
         return None
-    return plug
+
+    # If multiple plugs, prefer the non-script one (main power plug)
+    for plug in plugs:
+        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+        if not is_script:
+            return plug
+
+    # All are scripts, return the first one
+    return plugs[0]
+
+
+@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
+async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+    """Get all HA script plugs assigned to a printer.
+
+    Returns only script entities (script.*) for the printer that have
+    show_on_printer_card enabled.
+    Used to display "Run Script" buttons alongside the main power plug.
+    """
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    plugs = result.scalars().all()
+
+    # Filter to only scripts with show_on_printer_card enabled
+    scripts = [
+        plug
+        for plug in plugs
+        if plug.plug_type == "homeassistant"
+        and plug.ha_entity_id
+        and plug.ha_entity_id.startswith("script.")
+        and plug.show_on_printer_card
+    ]
+    return scripts
 
 
 
 
 # Tasmota Discovery Endpoints
 # Tasmota Discovery Endpoints
@@ -287,14 +392,43 @@ async def update_smart_plug(
             raise HTTPException(400, "Printer not found")
             raise HTTPException(400, "Printer not found")
 
 
         # Check if that printer already has a different plug assigned
         # Check if that printer already has a different plug assigned
-        result = await db.execute(
-            select(SmartPlug).where(
-                SmartPlug.printer_id == new_printer_id,
-                SmartPlug.id != plug_id,
+        # Scripts can coexist with other plugs
+        # Determine if the plug being updated is/will be a script
+        new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id)
+        new_plug_type = update_data.get("plug_type", plug.plug_type)
+        is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.")
+
+        if not is_script:
+            result = await db.execute(
+                select(SmartPlug).where(
+                    SmartPlug.printer_id == new_printer_id,
+                    SmartPlug.id != plug_id,
+                )
             )
             )
-        )
-        if result.scalar_one_or_none():
-            raise HTTPException(400, "This printer already has a smart plug assigned")
+            existing = result.scalar_one_or_none()
+            if existing:
+                # Allow if existing plug is a script
+                existing_is_script = (
+                    existing.plug_type == "homeassistant"
+                    and existing.ha_entity_id
+                    and existing.ha_entity_id.startswith("script.")
+                )
+                if not existing_is_script:
+                    raise HTTPException(400, "This printer already has a smart plug assigned")
+
+    # Track old MQTT settings for comparison
+    old_plug_type = plug.plug_type
+    old_mqtt_config = {
+        "power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
+        "power_path": plug.mqtt_power_path,
+        "power_multiplier": plug.mqtt_power_multiplier,
+        "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
+        "energy_path": plug.mqtt_energy_path,
+        "energy_multiplier": plug.mqtt_energy_multiplier,
+        "state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
+        "state_path": plug.mqtt_state_path,
+        "state_on_value": plug.mqtt_state_on_value,
+    }
 
 
     for field, value in update_data.items():
     for field, value in update_data.items():
         setattr(plug, field, value)
         setattr(plug, field, value)
@@ -302,6 +436,54 @@ async def update_smart_plug(
     await db.commit()
     await db.commit()
     await db.refresh(plug)
     await db.refresh(plug)
 
 
+    # Handle MQTT subscription changes
+    if old_plug_type == "mqtt" and plug.plug_type != "mqtt":
+        # Changed away from MQTT - unsubscribe
+        mqtt_relay.smart_plug_service.unsubscribe(plug.id)
+    elif plug.plug_type == "mqtt":
+        # Check if any MQTT config changed
+        new_mqtt_config = {
+            "power_topic": plug.mqtt_power_topic or plug.mqtt_topic,
+            "power_path": plug.mqtt_power_path,
+            "power_multiplier": plug.mqtt_power_multiplier,
+            "energy_topic": plug.mqtt_energy_topic or plug.mqtt_topic,
+            "energy_path": plug.mqtt_energy_path,
+            "energy_multiplier": plug.mqtt_energy_multiplier,
+            "state_topic": plug.mqtt_state_topic or plug.mqtt_topic,
+            "state_path": plug.mqtt_state_path,
+            "state_on_value": plug.mqtt_state_on_value,
+        }
+
+        mqtt_changed = old_plug_type != "mqtt" or old_mqtt_config != new_mqtt_config
+
+        if mqtt_changed:
+            # Unsubscribe from old topics first
+            if old_plug_type == "mqtt":
+                mqtt_relay.smart_plug_service.unsubscribe(plug.id)
+
+            # Subscribe to new topics
+            power_topic = plug.mqtt_power_topic or plug.mqtt_topic
+            energy_topic = plug.mqtt_energy_topic
+            state_topic = plug.mqtt_state_topic
+
+            # Only subscribe if at least one topic is configured
+            if power_topic or energy_topic or state_topic:
+                mqtt_relay.smart_plug_service.subscribe(
+                    plug_id=plug.id,
+                    # Power source (path is optional)
+                    power_topic=power_topic,
+                    power_path=plug.mqtt_power_path,
+                    power_multiplier=plug.mqtt_power_multiplier or plug.mqtt_multiplier or 1.0,
+                    # Energy source (path is optional)
+                    energy_topic=energy_topic,
+                    energy_path=plug.mqtt_energy_path,
+                    energy_multiplier=plug.mqtt_energy_multiplier or plug.mqtt_multiplier or 1.0,
+                    # State source (path is optional)
+                    state_topic=state_topic,
+                    state_path=plug.mqtt_state_path,
+                    state_on_value=plug.mqtt_state_on_value,
+                )
+
     logger.info(f"Updated smart plug '{plug.name}'")
     logger.info(f"Updated smart plug '{plug.name}'")
     return plug
     return plug
 
 
@@ -315,6 +497,12 @@ async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
     plug_name = plug.name
     plug_name = plug.name
+    plug_type = plug.plug_type
+
+    # Unsubscribe MQTT plug before deletion
+    if plug_type == "mqtt":
+        mqtt_relay.smart_plug_service.unsubscribe(plug_id)
+
     await db.delete(plug)
     await db.delete(plug)
     await db.commit()
     await db.commit()
 
 
@@ -348,6 +536,13 @@ async def control_smart_plug(
     if not plug:
     if not plug:
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
+    # MQTT plugs are monitor-only - cannot control them
+    if plug.plug_type == "mqtt":
+        raise HTTPException(
+            400,
+            "MQTT plugs are monitor-only. Use your MQTT broker or home automation system to control them.",
+        )
+
     service = await _get_service_for_plug(plug, db)
     service = await _get_service_for_plug(plug, db)
 
 
     if control.action == "on":
     if control.action == "on":
@@ -376,6 +571,13 @@ async def control_smart_plug(
     plug.last_checked = datetime.utcnow()
     plug.last_checked = datetime.utcnow()
     await db.commit()
     await db.commit()
 
 
+    # Trigger associated scripts if this is a main (non-script) plug
+    is_main_plug = not (
+        plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+    )
+    if is_main_plug and plug.printer_id and expected_state:
+        await trigger_associated_scripts(plug.printer_id, expected_state, db)
+
     # MQTT relay - publish smart plug state change
     # MQTT relay - publish smart plug state change
     if expected_state:
     if expected_state:
         try:
         try:
@@ -401,6 +603,37 @@ async def control_smart_plug(
     return {"success": True, "action": control.action}
     return {"success": True, "action": control.action}
 
 
 
 
+async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession):
+    """Trigger scripts linked to a printer based on main plug state change.
+
+    When the main plug turns ON, triggers scripts with auto_on=True.
+    When the main plug turns OFF, triggers scripts with auto_off=True.
+    """
+    result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
+    plugs = result.scalars().all()
+
+    # Find scripts that should be triggered
+    for plug in plugs:
+        is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
+        if not is_script:
+            continue
+
+        should_trigger = False
+        if plug_state == "ON" and plug.auto_on:
+            should_trigger = True
+            logger.info(f"Auto-triggering script '{plug.name}' on printer power-on")
+        elif plug_state == "OFF" and plug.auto_off:
+            should_trigger = True
+            logger.info(f"Auto-triggering script '{plug.name}' on printer power-off")
+
+        if should_trigger:
+            try:
+                service = await _get_service_for_plug(plug, db)
+                await service.turn_on(plug)  # Scripts are triggered by calling turn_on
+            except Exception as e:
+                logger.error(f"Failed to trigger script '{plug.name}': {e}")
+
+
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
 async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get current plug status from device including energy data."""
     """Get current plug status from device including energy data."""
@@ -409,6 +642,44 @@ async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
     if not plug:
     if not plug:
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
 
 
+    # Handle MQTT plugs - get data from subscription service
+    if plug.plug_type == "mqtt":
+        data = mqtt_relay.smart_plug_service.get_plug_data(plug_id)
+        is_reachable = mqtt_relay.smart_plug_service.is_reachable(plug_id)
+
+        if data:
+            # Update last state in database
+            if is_reachable and data.state:
+                plug.last_state = data.state
+                plug.last_checked = datetime.utcnow()
+                await db.commit()
+
+            energy_data = None
+            if data.power is not None or data.energy is not None:
+                energy_data = SmartPlugEnergy(
+                    power=data.power,
+                    today=data.energy,
+                )
+                # Check power alerts
+                if data.power is not None:
+                    await check_power_alerts(plug, data.power, db)
+
+            return SmartPlugStatus(
+                state=data.state,
+                reachable=is_reachable,
+                device_name=None,
+                energy=energy_data,
+            )
+
+        # No data received yet
+        return SmartPlugStatus(
+            state=None,
+            reachable=False,
+            device_name=None,
+            energy=None,
+        )
+
+    # Handle Tasmota/HomeAssistant plugs
     service = await _get_service_for_plug(plug, db)
     service = await _get_service_for_plug(plug, db)
     status = await service.get_status(plug)
     status = await service.get_status(plug)
 
 

+ 138 - 55
backend/app/api/routes/users.py

@@ -1,44 +1,57 @@
 from fastapi import APIRouter, Depends, HTTPException, status
 from fastapi import APIRouter, Depends, HTTPException, status
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 
-from backend.app.core.auth import RequireAdmin, get_password_hash
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    get_current_user_optional,
+    get_password_hash,
+    verify_password,
+)
 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.models.group import Group
 from backend.app.models.user import User
 from backend.app.models.user import User
-from backend.app.schemas.auth import UserCreate, UserResponse, UserUpdate
+from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
 
 
 router = APIRouter(prefix="/users", tags=["users"])
 router = APIRouter(prefix="/users", tags=["users"])
 
 
 
 
+def _user_to_response(user: User) -> UserResponse:
+    """Convert a User model to UserResponse schema."""
+    return UserResponse(
+        id=user.id,
+        username=user.username,
+        role=user.role,
+        is_active=user.is_active,
+        is_admin=user.is_admin,
+        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
+        permissions=sorted(user.get_permissions()),
+        created_at=user.created_at.isoformat(),
+    )
+
+
 @router.get("", response_model=list[UserResponse])
 @router.get("", response_model=list[UserResponse])
 @router.get("/", response_model=list[UserResponse])
 @router.get("/", response_model=list[UserResponse])
 async def list_users(
 async def list_users(
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """List all users (admin only)."""
-    result = await db.execute(select(User).order_by(User.created_at))
+    """List all users."""
+    result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
     users = result.scalars().all()
     users = result.scalars().all()
-    return [
-        UserResponse(
-            id=user.id,
-            username=user.username,
-            role=user.role,
-            is_active=user.is_active,
-            created_at=user.created_at.isoformat(),
-        )
-        for user in users
-    ]
+    return [_user_to_response(user) for user in users]
 
 
 
 
 @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 async def create_user(
 async def create_user(
     user_data: UserCreate,
     user_data: UserCreate,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Create a new user (admin only)."""
+    """Create a new user."""
     # Check if username already exists
     # Check if username already exists
     existing_user = await db.execute(select(User).where(User.username == user_data.username))
     existing_user = await db.execute(select(User).where(User.username == user_data.username))
     if existing_user.scalar_one_or_none():
     if existing_user.scalar_one_or_none():
@@ -60,27 +73,33 @@ async def create_user(
         role=user_data.role,
         role=user_data.role,
         is_active=True,
         is_active=True,
     )
     )
+
+    # Handle group assignments
+    if user_data.group_ids:
+        groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
+        groups = groups_result.scalars().all()
+        if len(groups) != len(user_data.group_ids):
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="One or more group IDs are invalid",
+            )
+        new_user.groups = list(groups)
+
     db.add(new_user)
     db.add(new_user)
     await db.commit()
     await db.commit()
     await db.refresh(new_user)
     await db.refresh(new_user)
 
 
-    return UserResponse(
-        id=new_user.id,
-        username=new_user.username,
-        role=new_user.role,
-        is_active=new_user.is_active,
-        created_at=new_user.created_at.isoformat(),
-    )
+    return _user_to_response(new_user)
 
 
 
 
 @router.get("/{user_id}", response_model=UserResponse)
 @router.get("/{user_id}", response_model=UserResponse)
 async def get_user(
 async def get_user(
     user_id: int,
     user_id: int,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Get a user by ID (admin only)."""
-    result = await db.execute(select(User).where(User.id == user_id))
+    """Get a user by ID."""
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     user = result.scalar_one_or_none()
     if not user:
     if not user:
         raise HTTPException(
         raise HTTPException(
@@ -88,24 +107,18 @@ async def get_user(
             detail="User not found",
             detail="User not found",
         )
         )
 
 
-    return UserResponse(
-        id=user.id,
-        username=user.username,
-        role=user.role,
-        is_active=user.is_active,
-        created_at=user.created_at.isoformat(),
-    )
+    return _user_to_response(user)
 
 
 
 
 @router.patch("/{user_id}", response_model=UserResponse)
 @router.patch("/{user_id}", response_model=UserResponse)
 async def update_user(
 async def update_user(
     user_id: int,
     user_id: int,
     user_data: UserUpdate,
     user_data: UserUpdate,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Update a user (admin only)."""
-    result = await db.execute(select(User).where(User.id == user_id))
+    """Update a user."""
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     user = result.scalar_one_or_none()
     if not user:
     if not user:
         raise HTTPException(
         raise HTTPException(
@@ -114,10 +127,21 @@ async def update_user(
         )
         )
 
 
     # Prevent deactivating the last admin
     # Prevent deactivating the last admin
-    if user_data.is_active is False and user.role == "admin":
+    if user_data.is_active is False and user.is_admin:
+        # Count admins by role or Administrators group membership
         admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
         admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
-        admin_count = len(admin_count_result.scalars().all())
-        if admin_count <= 1:
+        role_admins = admin_count_result.scalars().all()
+
+        # Also check for users in Administrators group
+        admin_group_result = await db.execute(
+            select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
+        )
+        admin_group = admin_group_result.scalar_one_or_none()
+        group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active]
+
+        # Combine unique admins
+        all_admins = {u.id for u in role_admins} | {u.id for u in group_admins}
+        if len(all_admins) <= 1 and user.id in all_admins:
             raise HTTPException(
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Cannot deactivate the last admin user",
                 detail="Cannot deactivate the last admin user",
@@ -157,26 +181,31 @@ async def update_user(
     if user_data.is_active is not None:
     if user_data.is_active is not None:
         user.is_active = user_data.is_active
         user.is_active = user_data.is_active
 
 
+    # Handle group assignments
+    if user_data.group_ids is not None:
+        groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
+        groups = groups_result.scalars().all()
+        if len(groups) != len(user_data.group_ids):
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="One or more group IDs are invalid",
+            )
+        user.groups = list(groups)
+
     await db.commit()
     await db.commit()
     await db.refresh(user)
     await db.refresh(user)
 
 
-    return UserResponse(
-        id=user.id,
-        username=user.username,
-        role=user.role,
-        is_active=user.is_active,
-        created_at=user.created_at.isoformat(),
-    )
+    return _user_to_response(user)
 
 
 
 
 @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_user(
 async def delete_user(
     user_id: int,
     user_id: int,
-    current_user: User = RequireAdmin(),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Delete a user (admin only)."""
-    result = await db.execute(select(User).where(User.id == user_id))
+    """Delete a user."""
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     user = result.scalar_one_or_none()
     if not user:
     if not user:
         raise HTTPException(
         raise HTTPException(
@@ -185,17 +214,28 @@ async def delete_user(
         )
         )
 
 
     # Prevent deleting the last admin
     # Prevent deleting the last admin
-    if user.role == "admin":
+    if user.is_admin:
+        # Count admins by role or Administrators group membership
         admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
         admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
-        admin_count = len(admin_count_result.scalars().all())
-        if admin_count == 0:
+        other_role_admins = admin_count_result.scalars().all()
+
+        # Also check for users in Administrators group
+        admin_group_result = await db.execute(
+            select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
+        )
+        admin_group = admin_group_result.scalar_one_or_none()
+        other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active]
+
+        # Combine unique admins
+        all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins}
+        if len(all_other_admins) == 0:
             raise HTTPException(
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Cannot delete the last admin user",
                 detail="Cannot delete the last admin user",
             )
             )
 
 
-    # Prevent deleting yourself
-    if user.id == current_user.id:
+    # Prevent deleting yourself (only if auth is enabled and we have a current user)
+    if current_user and user.id == current_user.id:
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             status_code=status.HTTP_400_BAD_REQUEST,
             detail="Cannot delete your own account",
             detail="Cannot delete your own account",
@@ -203,3 +243,46 @@ async def delete_user(
 
 
     await db.delete(user)
     await db.delete(user)
     await db.commit()
     await db.commit()
+
+
+@router.post("/me/change-password", response_model=dict)
+async def change_own_password(
+    password_data: ChangePasswordRequest,
+    current_user: User | None = Depends(get_current_user_optional),
+    db: AsyncSession = Depends(get_db),
+):
+    """Change the current user's password. Requires current password verification."""
+    if not current_user:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Authentication required to change password",
+        )
+
+    # Verify current password
+    if not verify_password(password_data.current_password, current_user.password_hash):
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Current password is incorrect",
+        )
+
+    # Validate new password
+    if len(password_data.new_password) < 6:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="New password must be at least 6 characters",
+        )
+
+    # Fetch user from this session to ensure changes are persisted
+    result = await db.execute(select(User).where(User.id == current_user.id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    # Update password
+    user.password_hash = get_password_hash(password_data.new_password)
+    await db.commit()
+
+    return {"message": "Password changed successfully"}

+ 125 - 2
backend/app/core/auth.py

@@ -11,8 +11,10 @@ from jwt.exceptions import PyJWTError as JWTError
 from passlib.context import CryptContext
 from passlib.context import CryptContext
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -60,8 +62,8 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
 
 
 
 
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
-    """Get a user by username."""
-    result = await db.execute(select(User).where(User.username == username))
+    """Get a user by username with groups loaded for permission checks."""
+    result = await db.execute(select(User).where(User.username == username).options(selectinload(User.groups)))
     return result.scalar_one_or_none()
     return result.scalar_one_or_none()
 
 
 
 
@@ -347,3 +349,124 @@ def RequireAdmin():
 def RequireAdminIfAuthEnabled():
 def RequireAdminIfAuthEnabled():
     """Dependency that requires admin role if auth is enabled."""
     """Dependency that requires admin role if auth is enabled."""
     return Depends(require_admin_if_auth_enabled())
     return Depends(require_admin_if_auth_enabled())
+
+
+def require_permission(*permissions: str | Permission):
+    """Dependency factory that requires user to have ALL specified permissions.
+
+    Args:
+        *permissions: Permission strings or Permission enum values to require
+
+    Returns:
+        A dependency function that validates permissions
+    """
+    # Convert Permission enums to strings
+    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
+
+    async def permission_checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    ) -> User:
+        credentials_exception = HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Could not validate credentials",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+        if credentials is None:
+            raise credentials_exception
+
+        try:
+            token = credentials.credentials
+            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+            username: str = payload.get("sub")
+            if username is None:
+                raise credentials_exception
+        except JWTError:
+            raise credentials_exception
+
+        async with async_session() as db:
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise credentials_exception
+
+            if not user.has_all_permissions(*perm_strings):
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing required permissions: {', '.join(perm_strings)}",
+                )
+            return user
+
+    return permission_checker
+
+
+def require_permission_if_auth_enabled(*permissions: str | Permission):
+    """Dependency factory that checks permissions only if auth is enabled.
+
+    This provides backward compatibility - when auth is disabled, all access is allowed.
+
+    Args:
+        *permissions: Permission strings or Permission enum values to require
+
+    Returns:
+        A dependency function that validates permissions if auth is enabled
+    """
+    # Convert Permission enums to strings
+    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
+
+    async def permission_checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    ) -> User | None:
+        async with async_session() as db:
+            auth_enabled = await is_auth_enabled(db)
+            if not auth_enabled:
+                return None  # Auth disabled, allow access
+
+            if credentials is None:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Authentication required",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            try:
+                token = credentials.credentials
+                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+                username: str = payload.get("sub")
+                if username is None:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+            except JWTError:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            if not user.has_all_permissions(*perm_strings):
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing required permissions: {', '.join(perm_strings)}",
+                )
+            return user
+
+    return permission_checker
+
+
+def RequirePermission(*permissions: str | Permission):
+    """Convenience dependency that requires ALL specified permissions."""
+    return Depends(require_permission(*permissions))
+
+
+def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
+    """Convenience dependency that requires permissions if auth is enabled."""
+    return Depends(require_permission_if_auth_enabled(*permissions))

+ 223 - 23
backend/app/core/database.py

@@ -39,6 +39,7 @@ async def init_db():
         external_link,
         external_link,
         filament,
         filament,
         github_backup,
         github_backup,
+        group,
         kprofile_note,
         kprofile_note,
         library,
         library,
         maintenance,
         maintenance,
@@ -62,6 +63,9 @@ async def init_db():
     # Seed default notification templates
     # Seed default notification templates
     await seed_notification_templates()
     await seed_notification_templates()
 
 
+    # Seed default groups and migrate existing users
+    await seed_default_groups()
+
 
 
 async def run_migrations(conn):
 async def run_migrations(conn):
     """Add new columns to existing tables if they don't exist."""
     """Add new columns to existing tables if they don't exist."""
@@ -760,45 +764,174 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         pass
 
 
-    # Migration: Add sliced_for_model column to print_archives for printer model from 3MF
+    # Migration: Remove UNIQUE constraint from smart_plugs.printer_id
+    # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
+    # SQLite requires table recreation to drop constraints
+    try:
+        # Check if we need to migrate (if UNIQUE constraint exists)
+        result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
+        row = result.fetchone()
+        if row and "printer_id INTEGER UNIQUE" in (row[0] or ""):
+            # Create new table without UNIQUE constraint on printer_id
+            await conn.execute(
+                text("""
+                CREATE TABLE smart_plugs_temp (
+                    id INTEGER PRIMARY KEY,
+                    name VARCHAR(100) NOT NULL,
+                    ip_address VARCHAR(45),
+                    plug_type VARCHAR(20) DEFAULT 'tasmota',
+                    ha_entity_id VARCHAR(100),
+                    ha_power_entity VARCHAR(100),
+                    ha_energy_today_entity VARCHAR(100),
+                    ha_energy_total_entity VARCHAR(100),
+                    printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
+                    enabled BOOLEAN NOT NULL DEFAULT 1,
+                    auto_on BOOLEAN NOT NULL DEFAULT 1,
+                    auto_off BOOLEAN NOT NULL DEFAULT 1,
+                    off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
+                    off_delay_minutes INTEGER NOT NULL DEFAULT 5,
+                    off_temp_threshold INTEGER NOT NULL DEFAULT 70,
+                    username VARCHAR(50),
+                    password VARCHAR(100),
+                    power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
+                    power_alert_high FLOAT,
+                    power_alert_low FLOAT,
+                    power_alert_last_triggered DATETIME,
+                    schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
+                    schedule_on_time VARCHAR(5),
+                    schedule_off_time VARCHAR(5),
+                    show_in_switchbar BOOLEAN DEFAULT 0,
+                    last_state VARCHAR(10),
+                    last_checked DATETIME,
+                    auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
+                    auto_off_pending BOOLEAN DEFAULT 0,
+                    auto_off_pending_since DATETIME,
+                    created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
+                    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
+                )
+            """)
+            )
+            # Copy data
+            await conn.execute(
+                text("""
+                INSERT INTO smart_plugs_temp
+                SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
+                       ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
+                       auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
+                       username, password, power_alert_enabled, power_alert_high, power_alert_low,
+                       power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
+                       show_in_switchbar, last_state, last_checked, auto_off_executed,
+                       auto_off_pending, auto_off_pending_since, created_at, updated_at
+                FROM smart_plugs
+            """)
+            )
+            # Drop old table and rename new one
+            await conn.execute(text("DROP TABLE smart_plugs"))
+            await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
+    except Exception:
+        pass
+
+    # Migration: Add show_on_printer_card column to smart_plugs
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1"))
+    except Exception:
+        pass
+
+    # Migration: Add MQTT smart plug fields (legacy)
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)"))
+    except Exception:
+        pass
     try:
     try:
-        await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0"))
     except Exception:
     except Exception:
         pass
         pass
 
 
-    # Migration: Add target_model column to print_queue for model-based assignment
+    # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)"))
+    except Exception:
+        pass
     try:
     try:
-        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)"))
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)"))
     except Exception:
     except Exception:
         pass
         pass
 
 
-    # Migration: Add required_filament_types column to print_queue for filament validation
+    # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility
     try:
     try:
-        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT"))
+        await conn.execute(
+            text("""
+            UPDATE smart_plugs
+            SET mqtt_power_topic = mqtt_topic,
+                mqtt_power_multiplier = mqtt_multiplier
+            WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL
+        """)
+        )
     except Exception:
     except Exception:
         pass
         pass
 
 
-    # Migration: Add waiting_reason column to print_queue for status feedback
+    # Migration: Create groups table for permission-based access control
     try:
     try:
-        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT"))
+        await conn.execute(
+            text("""
+            CREATE TABLE IF NOT EXISTS groups (
+                id INTEGER PRIMARY KEY,
+                name VARCHAR(100) NOT NULL UNIQUE,
+                description VARCHAR(500),
+                permissions JSON,
+                is_system BOOLEAN NOT NULL DEFAULT 0,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+        """)
+        )
+        await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)"))
     except Exception:
     except Exception:
         pass
         pass
 
 
-    # Migration: Add queue notification event columns to notification_providers
-    queue_notification_columns = [
-        ("on_queue_job_added", "BOOLEAN DEFAULT 0"),
-        ("on_queue_job_assigned", "BOOLEAN DEFAULT 0"),
-        ("on_queue_job_started", "BOOLEAN DEFAULT 0"),
-        ("on_queue_job_waiting", "BOOLEAN DEFAULT 1"),
-        ("on_queue_job_skipped", "BOOLEAN DEFAULT 1"),
-        ("on_queue_job_failed", "BOOLEAN DEFAULT 1"),
-        ("on_queue_completed", "BOOLEAN DEFAULT 0"),
-    ]
-    for col_name, col_def in queue_notification_columns:
-        try:
-            await conn.execute(text(f"ALTER TABLE notification_providers ADD COLUMN {col_name} {col_def}"))
-        except Exception:
-            pass
+    # Migration: Create user_groups association table
+    try:
+        await conn.execute(
+            text("""
+            CREATE TABLE IF NOT EXISTS user_groups (
+                user_id INTEGER NOT NULL,
+                group_id INTEGER NOT NULL,
+                PRIMARY KEY (user_id, group_id),
+                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+                FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
+            )
+        """)
+        )
+    except Exception:
+        pass
 
 
 
 
 async def seed_notification_templates():
 async def seed_notification_templates():
@@ -837,3 +970,70 @@ async def seed_notification_templates():
                     session.add(template)
                     session.add(template)
 
 
         await session.commit()
         await session.commit()
+
+
+async def seed_default_groups():
+    """Seed default groups and migrate existing users to appropriate groups.
+
+    Creates the default system groups (Administrators, Operators, Viewers) if they
+    don't exist, then migrates existing users:
+    - Users with role='admin' -> Administrators group
+    - Users with role='user' -> Operators group
+    """
+    import logging
+
+    from sqlalchemy import select
+
+    from backend.app.core.permissions import DEFAULT_GROUPS
+    from backend.app.models.group import Group
+    from backend.app.models.user import User
+
+    logger = logging.getLogger(__name__)
+
+    async with async_session() as session:
+        # Get existing groups
+        result = await session.execute(select(Group.name))
+        existing_groups = {row[0] for row in result.fetchall()}
+
+        # Create default groups if they don't exist
+        groups_created = []
+        for group_name, group_config in DEFAULT_GROUPS.items():
+            if group_name not in existing_groups:
+                group = Group(
+                    name=group_name,
+                    description=group_config["description"],
+                    permissions=group_config["permissions"],
+                    is_system=group_config["is_system"],
+                )
+                session.add(group)
+                groups_created.append(group_name)
+                logger.info(f"Created default group: {group_name}")
+
+        await session.commit()
+
+        # Migrate existing users to groups if they're not already in any group
+        if groups_created:
+            # Get the groups we need
+            admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
+            admin_group = admin_result.scalar_one_or_none()
+
+            operators_result = await session.execute(select(Group).where(Group.name == "Operators"))
+            operators_group = operators_result.scalar_one_or_none()
+
+            # Get all users
+            users_result = await session.execute(select(User))
+            users = users_result.scalars().all()
+
+            for user in users:
+                # Skip if user already has groups
+                if user.groups:
+                    continue
+
+                if user.role == "admin" and admin_group:
+                    user.groups.append(admin_group)
+                    logger.info(f"Migrated admin user '{user.username}' to Administrators group")
+                elif operators_group:
+                    user.groups.append(operators_group)
+                    logger.info(f"Migrated user '{user.username}' to Operators group")
+
+            await session.commit()

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

@@ -0,0 +1,392 @@
+"""Permission definitions for the group-based access control system.
+
+This module defines all permissions using a string enum with `resource:action` naming.
+Permissions are additive across groups - a user has all permissions from all their groups.
+"""
+
+from enum import Enum
+
+
+class Permission(str, Enum):
+    """All available permissions in the system.
+
+    Permissions follow the pattern: resource:action
+    Actions typically include: read, create, update, delete, plus resource-specific actions.
+    """
+
+    # Printers
+    PRINTERS_READ = "printers:read"
+    PRINTERS_CREATE = "printers:create"
+    PRINTERS_UPDATE = "printers:update"
+    PRINTERS_DELETE = "printers:delete"
+    PRINTERS_CONTROL = "printers:control"  # Start/stop/pause/resume prints
+    PRINTERS_FILES = "printers:files"  # Send files to printer
+
+    # Archives
+    ARCHIVES_READ = "archives:read"
+    ARCHIVES_CREATE = "archives:create"
+    ARCHIVES_UPDATE = "archives:update"
+    ARCHIVES_DELETE = "archives:delete"
+    ARCHIVES_REPRINT = "archives:reprint"  # Reprint from archive
+
+    # Queue
+    QUEUE_READ = "queue:read"
+    QUEUE_CREATE = "queue:create"
+    QUEUE_UPDATE = "queue:update"
+    QUEUE_DELETE = "queue:delete"
+    QUEUE_REORDER = "queue:reorder"
+
+    # Library
+    LIBRARY_READ = "library:read"
+    LIBRARY_UPLOAD = "library:upload"
+    LIBRARY_UPDATE = "library:update"
+    LIBRARY_DELETE = "library:delete"
+
+    # Projects
+    PROJECTS_READ = "projects:read"
+    PROJECTS_CREATE = "projects:create"
+    PROJECTS_UPDATE = "projects:update"
+    PROJECTS_DELETE = "projects:delete"
+
+    # Filaments
+    FILAMENTS_READ = "filaments:read"
+    FILAMENTS_CREATE = "filaments:create"
+    FILAMENTS_UPDATE = "filaments:update"
+    FILAMENTS_DELETE = "filaments:delete"
+
+    # Smart Plugs
+    SMART_PLUGS_READ = "smart_plugs:read"
+    SMART_PLUGS_CREATE = "smart_plugs:create"
+    SMART_PLUGS_UPDATE = "smart_plugs:update"
+    SMART_PLUGS_DELETE = "smart_plugs:delete"
+    SMART_PLUGS_CONTROL = "smart_plugs:control"  # Turn on/off
+
+    # Camera
+    CAMERA_VIEW = "camera:view"
+
+    # Maintenance
+    MAINTENANCE_READ = "maintenance:read"
+    MAINTENANCE_CREATE = "maintenance:create"
+    MAINTENANCE_UPDATE = "maintenance:update"
+    MAINTENANCE_DELETE = "maintenance:delete"
+
+    # K-Profiles
+    KPROFILES_READ = "kprofiles:read"
+    KPROFILES_CREATE = "kprofiles:create"
+    KPROFILES_UPDATE = "kprofiles:update"
+    KPROFILES_DELETE = "kprofiles:delete"
+
+    # Notifications
+    NOTIFICATIONS_READ = "notifications:read"
+    NOTIFICATIONS_CREATE = "notifications:create"
+    NOTIFICATIONS_UPDATE = "notifications:update"
+    NOTIFICATIONS_DELETE = "notifications:delete"
+
+    # Notification Templates
+    NOTIFICATION_TEMPLATES_READ = "notification_templates:read"
+    NOTIFICATION_TEMPLATES_UPDATE = "notification_templates:update"
+
+    # External Links
+    EXTERNAL_LINKS_READ = "external_links:read"
+    EXTERNAL_LINKS_CREATE = "external_links:create"
+    EXTERNAL_LINKS_UPDATE = "external_links:update"
+    EXTERNAL_LINKS_DELETE = "external_links:delete"
+
+    # Discovery (network scanning)
+    DISCOVERY_SCAN = "discovery:scan"
+
+    # Firmware
+    FIRMWARE_READ = "firmware:read"
+    FIRMWARE_UPDATE = "firmware:update"
+
+    # AMS History
+    AMS_HISTORY_READ = "ams_history:read"
+
+    # Stats/Metrics
+    STATS_READ = "stats:read"
+
+    # System Info
+    SYSTEM_READ = "system:read"
+
+    # Settings (admin-level)
+    SETTINGS_READ = "settings:read"
+    SETTINGS_UPDATE = "settings:update"
+    SETTINGS_BACKUP = "settings:backup"
+    SETTINGS_RESTORE = "settings:restore"
+
+    # GitHub Backup (admin-level)
+    GITHUB_BACKUP = "github:backup"
+    GITHUB_RESTORE = "github:restore"
+
+    # Cloud Auth (admin-level)
+    CLOUD_AUTH = "cloud:auth"
+
+    # API Keys (admin-level)
+    API_KEYS_READ = "api_keys:read"
+    API_KEYS_CREATE = "api_keys:create"
+    API_KEYS_UPDATE = "api_keys:update"
+    API_KEYS_DELETE = "api_keys:delete"
+
+    # Users (admin-level)
+    USERS_READ = "users:read"
+    USERS_CREATE = "users:create"
+    USERS_UPDATE = "users:update"
+    USERS_DELETE = "users:delete"
+
+    # Groups (admin-level)
+    GROUPS_READ = "groups:read"
+    GROUPS_CREATE = "groups:create"
+    GROUPS_UPDATE = "groups:update"
+    GROUPS_DELETE = "groups:delete"
+
+    # WebSocket connection
+    WEBSOCKET_CONNECT = "websocket:connect"
+
+
+# Permission categories for UI organization
+PERMISSION_CATEGORIES = {
+    "Printers": [
+        Permission.PRINTERS_READ,
+        Permission.PRINTERS_CREATE,
+        Permission.PRINTERS_UPDATE,
+        Permission.PRINTERS_DELETE,
+        Permission.PRINTERS_CONTROL,
+        Permission.PRINTERS_FILES,
+    ],
+    "Archives": [
+        Permission.ARCHIVES_READ,
+        Permission.ARCHIVES_CREATE,
+        Permission.ARCHIVES_UPDATE,
+        Permission.ARCHIVES_DELETE,
+        Permission.ARCHIVES_REPRINT,
+    ],
+    "Queue": [
+        Permission.QUEUE_READ,
+        Permission.QUEUE_CREATE,
+        Permission.QUEUE_UPDATE,
+        Permission.QUEUE_DELETE,
+        Permission.QUEUE_REORDER,
+    ],
+    "Library": [
+        Permission.LIBRARY_READ,
+        Permission.LIBRARY_UPLOAD,
+        Permission.LIBRARY_UPDATE,
+        Permission.LIBRARY_DELETE,
+    ],
+    "Projects": [
+        Permission.PROJECTS_READ,
+        Permission.PROJECTS_CREATE,
+        Permission.PROJECTS_UPDATE,
+        Permission.PROJECTS_DELETE,
+    ],
+    "Filaments": [
+        Permission.FILAMENTS_READ,
+        Permission.FILAMENTS_CREATE,
+        Permission.FILAMENTS_UPDATE,
+        Permission.FILAMENTS_DELETE,
+    ],
+    "Smart Plugs": [
+        Permission.SMART_PLUGS_READ,
+        Permission.SMART_PLUGS_CREATE,
+        Permission.SMART_PLUGS_UPDATE,
+        Permission.SMART_PLUGS_DELETE,
+        Permission.SMART_PLUGS_CONTROL,
+    ],
+    "Camera": [
+        Permission.CAMERA_VIEW,
+    ],
+    "Maintenance": [
+        Permission.MAINTENANCE_READ,
+        Permission.MAINTENANCE_CREATE,
+        Permission.MAINTENANCE_UPDATE,
+        Permission.MAINTENANCE_DELETE,
+    ],
+    "K-Profiles": [
+        Permission.KPROFILES_READ,
+        Permission.KPROFILES_CREATE,
+        Permission.KPROFILES_UPDATE,
+        Permission.KPROFILES_DELETE,
+    ],
+    "Notifications": [
+        Permission.NOTIFICATIONS_READ,
+        Permission.NOTIFICATIONS_CREATE,
+        Permission.NOTIFICATIONS_UPDATE,
+        Permission.NOTIFICATIONS_DELETE,
+        Permission.NOTIFICATION_TEMPLATES_READ,
+        Permission.NOTIFICATION_TEMPLATES_UPDATE,
+    ],
+    "External Links": [
+        Permission.EXTERNAL_LINKS_READ,
+        Permission.EXTERNAL_LINKS_CREATE,
+        Permission.EXTERNAL_LINKS_UPDATE,
+        Permission.EXTERNAL_LINKS_DELETE,
+    ],
+    "Discovery": [
+        Permission.DISCOVERY_SCAN,
+    ],
+    "Firmware": [
+        Permission.FIRMWARE_READ,
+        Permission.FIRMWARE_UPDATE,
+    ],
+    "Stats & History": [
+        Permission.AMS_HISTORY_READ,
+        Permission.STATS_READ,
+    ],
+    "System": [
+        Permission.SYSTEM_READ,
+    ],
+    "Settings": [
+        Permission.SETTINGS_READ,
+        Permission.SETTINGS_UPDATE,
+        Permission.SETTINGS_BACKUP,
+        Permission.SETTINGS_RESTORE,
+    ],
+    "Backup": [
+        Permission.GITHUB_BACKUP,
+        Permission.GITHUB_RESTORE,
+    ],
+    "Cloud": [
+        Permission.CLOUD_AUTH,
+    ],
+    "API Keys": [
+        Permission.API_KEYS_READ,
+        Permission.API_KEYS_CREATE,
+        Permission.API_KEYS_UPDATE,
+        Permission.API_KEYS_DELETE,
+    ],
+    "User Management": [
+        Permission.USERS_READ,
+        Permission.USERS_CREATE,
+        Permission.USERS_UPDATE,
+        Permission.USERS_DELETE,
+        Permission.GROUPS_READ,
+        Permission.GROUPS_CREATE,
+        Permission.GROUPS_UPDATE,
+        Permission.GROUPS_DELETE,
+    ],
+    "WebSocket": [
+        Permission.WEBSOCKET_CONNECT,
+    ],
+}
+
+
+# All permissions as a list
+ALL_PERMISSIONS = [p.value for p in Permission]
+
+
+# Default group definitions
+DEFAULT_GROUPS = {
+    "Administrators": {
+        "description": "Full access to all features and settings",
+        "permissions": ALL_PERMISSIONS,  # All permissions
+        "is_system": True,
+    },
+    "Operators": {
+        "description": "Can control printers, manage queue and archives, view settings",
+        "permissions": [
+            # Printers - full control
+            Permission.PRINTERS_READ.value,
+            Permission.PRINTERS_CREATE.value,
+            Permission.PRINTERS_UPDATE.value,
+            Permission.PRINTERS_DELETE.value,
+            Permission.PRINTERS_CONTROL.value,
+            Permission.PRINTERS_FILES.value,
+            # Archives - full access
+            Permission.ARCHIVES_READ.value,
+            Permission.ARCHIVES_CREATE.value,
+            Permission.ARCHIVES_UPDATE.value,
+            Permission.ARCHIVES_DELETE.value,
+            Permission.ARCHIVES_REPRINT.value,
+            # Queue - full access
+            Permission.QUEUE_READ.value,
+            Permission.QUEUE_CREATE.value,
+            Permission.QUEUE_UPDATE.value,
+            Permission.QUEUE_DELETE.value,
+            Permission.QUEUE_REORDER.value,
+            # Library - full access
+            Permission.LIBRARY_READ.value,
+            Permission.LIBRARY_UPLOAD.value,
+            Permission.LIBRARY_UPDATE.value,
+            Permission.LIBRARY_DELETE.value,
+            # Projects - full access
+            Permission.PROJECTS_READ.value,
+            Permission.PROJECTS_CREATE.value,
+            Permission.PROJECTS_UPDATE.value,
+            Permission.PROJECTS_DELETE.value,
+            # Filaments - full access
+            Permission.FILAMENTS_READ.value,
+            Permission.FILAMENTS_CREATE.value,
+            Permission.FILAMENTS_UPDATE.value,
+            Permission.FILAMENTS_DELETE.value,
+            # Smart Plugs - full access
+            Permission.SMART_PLUGS_READ.value,
+            Permission.SMART_PLUGS_CREATE.value,
+            Permission.SMART_PLUGS_UPDATE.value,
+            Permission.SMART_PLUGS_DELETE.value,
+            Permission.SMART_PLUGS_CONTROL.value,
+            # Camera - view
+            Permission.CAMERA_VIEW.value,
+            # Maintenance - full access
+            Permission.MAINTENANCE_READ.value,
+            Permission.MAINTENANCE_CREATE.value,
+            Permission.MAINTENANCE_UPDATE.value,
+            Permission.MAINTENANCE_DELETE.value,
+            # K-Profiles - full access
+            Permission.KPROFILES_READ.value,
+            Permission.KPROFILES_CREATE.value,
+            Permission.KPROFILES_UPDATE.value,
+            Permission.KPROFILES_DELETE.value,
+            # Notifications - full access
+            Permission.NOTIFICATIONS_READ.value,
+            Permission.NOTIFICATIONS_CREATE.value,
+            Permission.NOTIFICATIONS_UPDATE.value,
+            Permission.NOTIFICATIONS_DELETE.value,
+            Permission.NOTIFICATION_TEMPLATES_READ.value,
+            Permission.NOTIFICATION_TEMPLATES_UPDATE.value,
+            # External Links - full access
+            Permission.EXTERNAL_LINKS_READ.value,
+            Permission.EXTERNAL_LINKS_CREATE.value,
+            Permission.EXTERNAL_LINKS_UPDATE.value,
+            Permission.EXTERNAL_LINKS_DELETE.value,
+            # Discovery
+            Permission.DISCOVERY_SCAN.value,
+            # Firmware - read only
+            Permission.FIRMWARE_READ.value,
+            # Stats & History
+            Permission.AMS_HISTORY_READ.value,
+            Permission.STATS_READ.value,
+            Permission.SYSTEM_READ.value,
+            # Settings - read only
+            Permission.SETTINGS_READ.value,
+            # WebSocket
+            Permission.WEBSOCKET_CONNECT.value,
+        ],
+        "is_system": True,
+    },
+    "Viewers": {
+        "description": "Read-only access to printers, archives, and queue",
+        "permissions": [
+            # Read-only access
+            Permission.PRINTERS_READ.value,
+            Permission.ARCHIVES_READ.value,
+            Permission.QUEUE_READ.value,
+            Permission.LIBRARY_READ.value,
+            Permission.PROJECTS_READ.value,
+            Permission.FILAMENTS_READ.value,
+            Permission.SMART_PLUGS_READ.value,
+            Permission.CAMERA_VIEW.value,
+            Permission.MAINTENANCE_READ.value,
+            Permission.KPROFILES_READ.value,
+            Permission.NOTIFICATIONS_READ.value,
+            Permission.NOTIFICATION_TEMPLATES_READ.value,
+            Permission.EXTERNAL_LINKS_READ.value,
+            Permission.FIRMWARE_READ.value,
+            Permission.AMS_HISTORY_READ.value,
+            Permission.STATS_READ.value,
+            Permission.SYSTEM_READ.value,
+            Permission.SETTINGS_READ.value,
+            Permission.WEBSOCKET_CONNECT.value,
+        ],
+        "is_system": True,
+    },
+}

+ 36 - 1
backend/app/main.py

@@ -183,6 +183,7 @@ from backend.app.api.routes import (
     filaments,
     filaments,
     firmware,
     firmware,
     github_backup,
     github_backup,
+    groups,
     kprofiles,
     kprofiles,
     library,
     library,
     maintenance,
     maintenance,
@@ -248,9 +249,10 @@ _notified_hms_errors: dict[int, set[str]] = {}
 
 
 
 
 async def _get_plug_energy(plug, db) -> dict | None:
 async def _get_plug_energy(plug, db) -> dict | None:
-    """Get energy from plug regardless of type (Tasmota or Home Assistant).
+    """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
 
 
     For HA plugs, configures the service with current settings from DB.
     For HA plugs, configures the service with current settings from DB.
+    For MQTT plugs, returns data from the subscription service.
     """
     """
     if plug.plug_type == "homeassistant":
     if plug.plug_type == "homeassistant":
         from backend.app.api.routes.settings import get_setting
         from backend.app.api.routes.settings import get_setting
@@ -259,6 +261,17 @@ async def _get_plug_energy(plug, db) -> dict | None:
         ha_token = await get_setting(db, "ha_token") or ""
         ha_token = await get_setting(db, "ha_token") or ""
         homeassistant_service.configure(ha_url, ha_token)
         homeassistant_service.configure(ha_url, ha_token)
         return await homeassistant_service.get_energy(plug)
         return await homeassistant_service.get_energy(plug)
+    elif plug.plug_type == "mqtt":
+        # MQTT plugs report "today" energy, not lifetime total
+        # For per-print tracking, we use "today" as the counter (resets at midnight)
+        mqtt_data = mqtt_relay.smart_plug_service.get_plug_data(plug.id)
+        if mqtt_data:
+            return {
+                "power": mqtt_data.power,
+                "today": mqtt_data.energy,
+                "total": mqtt_data.energy,  # Use today as total for per-print calculations
+            }
+        return None
     else:
     else:
         return await tasmota_service.get_energy(plug)
         return await tasmota_service.get_energy(plug)
 
 
@@ -2395,6 +2408,27 @@ async def lifespan(app: FastAPI):
         }
         }
         await mqtt_relay.configure(mqtt_settings)
         await mqtt_relay.configure(mqtt_settings)
 
 
+        # Restore MQTT smart plug subscriptions
+        if mqtt_settings.get("mqtt_enabled"):
+            from sqlalchemy import select
+
+            from backend.app.models.smart_plug import SmartPlug
+
+            result = await db.execute(select(SmartPlug).where(SmartPlug.plug_type == "mqtt"))
+            mqtt_plugs = result.scalars().all()
+            for plug in mqtt_plugs:
+                if plug.mqtt_topic:
+                    mqtt_relay.smart_plug_service.subscribe(
+                        plug_id=plug.id,
+                        topic=plug.mqtt_topic,
+                        power_path=plug.mqtt_power_path,
+                        energy_path=plug.mqtt_energy_path,
+                        state_path=plug.mqtt_state_path,
+                        multiplier=plug.mqtt_multiplier or 1.0,
+                    )
+            if mqtt_plugs:
+                logging.info(f"Restored {len(mqtt_plugs)} MQTT smart plug subscriptions")
+
     # Connect to all active printers
     # Connect to all active printers
     async with async_session() as db:
     async with async_session() as db:
         await init_printer_connections(db)
         await init_printer_connections(db)
@@ -2493,6 +2527,7 @@ app = FastAPI(
 # API routes
 # API routes
 app.include_router(auth.router, prefix=app_settings.api_prefix)
 app.include_router(auth.router, prefix=app_settings.api_prefix)
 app.include_router(users.router, prefix=app_settings.api_prefix)
 app.include_router(users.router, prefix=app_settings.api_prefix)
+app.include_router(groups.router, prefix=app_settings.api_prefix)
 app.include_router(printers.router, prefix=app_settings.api_prefix)
 app.include_router(printers.router, prefix=app_settings.api_prefix)
 app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)

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

@@ -3,6 +3,7 @@ from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 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, user_groups
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
@@ -34,6 +35,8 @@ __all__ = [
     "LibraryFolder",
     "LibraryFolder",
     "LibraryFile",
     "LibraryFile",
     "User",
     "User",
+    "Group",
+    "user_groups",
     "GitHubBackupConfig",
     "GitHubBackupConfig",
     "GitHubBackupLog",
     "GitHubBackupLog",
 ]
 ]

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

@@ -0,0 +1,54 @@
+"""Group model for permission-based access control."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.types import JSON
+
+from backend.app.core.database import Base
+
+if TYPE_CHECKING:
+    from backend.app.models.user import User
+
+
+# Many-to-many association table between users and groups
+user_groups = Table(
+    "user_groups",
+    Base.metadata,
+    Column("user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
+    Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
+)
+
+
+class Group(Base):
+    """Group model for organizing users and assigning permissions.
+
+    Groups contain a list of permissions that are granted to all members.
+    Users can belong to multiple groups, and their permissions are additive.
+    System groups (Administrators, Operators, Viewers) cannot be deleted.
+    """
+
+    __tablename__ = "groups"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
+    description: Mapped[str | None] = mapped_column(String(500), nullable=True)
+    permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
+    is_system: Mapped[bool] = mapped_column(Boolean, default=False)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    # Relationship to users through association table
+    users: Mapped[list[User]] = relationship(
+        "User",
+        secondary=user_groups,
+        back_populates="groups",
+        lazy="selectin",
+    )
+
+    def __repr__(self) -> str:
+        return f"<Group {self.name}>"

+ 31 - 2
backend/app/models/smart_plug.py

@@ -7,7 +7,7 @@ from backend.app.core.database import Base
 
 
 
 
 class SmartPlug(Base):
 class SmartPlug(Base):
-    """Smart plug for printer power control (Tasmota or Home Assistant)."""
+    """Smart plug for printer power control (Tasmota, Home Assistant, or MQTT)."""
 
 
     __tablename__ = "smart_plugs"
     __tablename__ = "smart_plugs"
 
 
@@ -15,7 +15,7 @@ class SmartPlug(Base):
     name: Mapped[str] = mapped_column(String(100))
     name: Mapped[str] = mapped_column(String(100))
     ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)  # IPv4/IPv6 (required for Tasmota)
     ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)  # IPv4/IPv6 (required for Tasmota)
 
 
-    # Plug type: "tasmota" (default) or "homeassistant"
+    # Plug type: "tasmota" (default), "homeassistant", or "mqtt"
     plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
     plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
     # Home Assistant entity ID (e.g., "switch.printer_plug")
     # Home Assistant entity ID (e.g., "switch.printer_plug")
     ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
     ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
@@ -24,6 +24,32 @@ class SmartPlug(Base):
     ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_today
     ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_today
     ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_total
     ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_total
 
 
+    # MQTT plug fields (required when plug_type="mqtt")
+    # Legacy field - kept for backward compatibility, now use mqtt_power_topic
+    mqtt_topic: Mapped[str | None] = mapped_column(
+        String(200), nullable=True
+    )  # e.g., "zigbee2mqtt/shelly-working-room" (deprecated, use mqtt_power_topic)
+
+    # Power monitoring
+    mqtt_power_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for power data
+    mqtt_power_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "power_l1" or "data.power"
+    mqtt_power_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Unit conversion for power
+
+    # Energy monitoring
+    mqtt_energy_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for energy data
+    mqtt_energy_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "energy_l1"
+    mqtt_energy_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Unit conversion for energy
+
+    # State monitoring
+    mqtt_state_topic: Mapped[str | None] = mapped_column(String(200), nullable=True)  # Topic for state data
+    mqtt_state_path: Mapped[str | None] = mapped_column(String(100), nullable=True)  # e.g., "state_l1" for ON/OFF
+    mqtt_state_on_value: Mapped[str | None] = mapped_column(
+        String(50), nullable=True
+    )  # What value means "ON" (e.g., "ON", "true", "1")
+
+    # Legacy multiplier - kept for backward compatibility
+    mqtt_multiplier: Mapped[float] = mapped_column(Float, default=1.0)  # Deprecated, use mqtt_power_multiplier
+
     # Link to printer (1:1)
     # Link to printer (1:1)
     printer_id: Mapped[int | None] = mapped_column(
     printer_id: Mapped[int | None] = mapped_column(
         ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True
         ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True
@@ -57,6 +83,9 @@ class SmartPlug(Base):
     # Switchbar visibility
     # Switchbar visibility
     show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)
     show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False)
 
 
+    # Printer card visibility (for scripts)
+    show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True)
+
     # Status tracking
     # Status tracking
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 82 - 3
backend/app/models/user.py

@@ -1,20 +1,99 @@
+from __future__ import annotations
+
 from datetime import datetime
 from datetime import datetime
+from typing import TYPE_CHECKING
 
 
 from sqlalchemy import DateTime, String, func
 from sqlalchemy import DateTime, String, func
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
 
 
+if TYPE_CHECKING:
+    from backend.app.models.group import Group
+
 
 
 class User(Base):
 class User(Base):
-    """User model for authentication and authorization."""
+    """User model for authentication and authorization.
+
+    Users can belong to multiple groups, and their permissions are additive
+    across all groups. The legacy 'role' field is kept for backward compatibility
+    but is_admin property now also considers group membership.
+    """
 
 
     __tablename__ = "users"
     __tablename__ = "users"
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     password_hash: Mapped[str] = mapped_column(String(255))
     password_hash: Mapped[str] = mapped_column(String(255))
-    role: Mapped[str] = mapped_column(String(20), default="user")  # "admin" or "user"
+    role: Mapped[str] = mapped_column(
+        String(20), default="user"
+    )  # "admin" or "user" (legacy, kept for backward compat)
     is_active: Mapped[bool] = mapped_column(default=True)
     is_active: Mapped[bool] = mapped_column(default=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    # Relationship to groups through association table
+    groups: Mapped[list[Group]] = relationship(
+        "Group",
+        secondary="user_groups",
+        back_populates="users",
+        lazy="selectin",
+    )
+
+    @property
+    def is_admin(self) -> bool:
+        """Check if user is an admin.
+
+        Returns True if:
+        - User has legacy role='admin', OR
+        - User belongs to the Administrators group
+        """
+        if self.role == "admin":
+            return True
+        return any(g.name == "Administrators" for g in self.groups)
+
+    def get_permissions(self) -> set[str]:
+        """Get all permissions from all groups the user belongs to.
+
+        Returns a set of permission strings. Permissions are additive across groups.
+        """
+        permissions: set[str] = set()
+        for group in self.groups:
+            if group.permissions:
+                permissions.update(group.permissions)
+        return permissions
+
+    def has_permission(self, permission: str) -> bool:
+        """Check if user has a specific permission.
+
+        Admins have all permissions. For other users, checks if the permission
+        exists in any of their groups.
+        """
+        if self.is_admin:
+            return True
+        return permission in self.get_permissions()
+
+    def has_all_permissions(self, *permissions: str) -> bool:
+        """Check if user has ALL specified permissions.
+
+        Admins have all permissions. For other users, checks if all permissions
+        exist in their combined group permissions.
+        """
+        if self.is_admin:
+            return True
+        user_permissions = self.get_permissions()
+        return all(p in user_permissions for p in permissions)
+
+    def has_any_permission(self, *permissions: str) -> bool:
+        """Check if user has ANY of the specified permissions.
+
+        Admins have all permissions. For other users, checks if at least one
+        permission exists in their combined group permissions.
+        """
+        if self.is_admin:
+            return True
+        user_permissions = self.get_permissions()
+        return any(p in user_permissions for p in permissions)
+
+    def __repr__(self) -> str:
+        return f"<User {self.username}>"

+ 21 - 1
backend/app/schemas/auth.py

@@ -1,6 +1,16 @@
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
 
 
+class GroupBrief(BaseModel):
+    """Brief group info for embedding in user responses."""
+
+    id: int
+    name: str
+
+    class Config:
+        from_attributes = True
+
+
 class LoginRequest(BaseModel):
 class LoginRequest(BaseModel):
     username: str
     username: str
     password: str
     password: str
@@ -16,6 +26,7 @@ class UserCreate(BaseModel):
     username: str
     username: str
     password: str
     password: str
     role: str = "user"
     role: str = "user"
+    group_ids: list[int] | None = None
 
 
 
 
 class UserUpdate(BaseModel):
 class UserUpdate(BaseModel):
@@ -23,19 +34,28 @@ class UserUpdate(BaseModel):
     password: str | None = None
     password: str | None = None
     role: str | None = None
     role: str | None = None
     is_active: bool | None = None
     is_active: bool | None = None
+    group_ids: list[int] | None = None
 
 
 
 
 class UserResponse(BaseModel):
 class UserResponse(BaseModel):
     id: int
     id: int
     username: str
     username: str
-    role: str
+    role: str  # Deprecated, kept for backward compatibility
     is_active: bool
     is_active: bool
+    is_admin: bool  # Computed from role and group membership
+    groups: list[GroupBrief] = []
+    permissions: list[str] = []  # All permissions from groups
     created_at: str
     created_at: str
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
 
 
 
 
+class ChangePasswordRequest(BaseModel):
+    current_password: str
+    new_password: str
+
+
 class SetupRequest(BaseModel):
 class SetupRequest(BaseModel):
     auth_enabled: bool
     auth_enabled: bool
     admin_username: str | None = None
     admin_username: str | None = None

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

@@ -0,0 +1,89 @@
+"""Pydantic schemas for Group CRUD operations."""
+
+from datetime import datetime
+
+from pydantic import BaseModel
+
+
+class GroupBrief(BaseModel):
+    """Brief group info for embedding in other responses."""
+
+    id: int
+    name: str
+
+    class Config:
+        from_attributes = True
+
+
+class GroupCreate(BaseModel):
+    """Schema for creating a new group."""
+
+    name: str
+    description: str | None = None
+    permissions: list[str] = []
+
+
+class GroupUpdate(BaseModel):
+    """Schema for updating a group."""
+
+    name: str | None = None
+    description: str | None = None
+    permissions: list[str] | None = None
+
+
+class GroupResponse(BaseModel):
+    """Schema for group response."""
+
+    id: int
+    name: str
+    description: str | None
+    permissions: list[str]
+    is_system: bool
+    user_count: int = 0
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class GroupDetailResponse(GroupResponse):
+    """Schema for detailed group response including users."""
+
+    users: list["UserBrief"] = []
+
+
+class UserBrief(BaseModel):
+    """Brief user info for embedding in group response."""
+
+    id: int
+    username: str
+    is_active: bool
+
+    class Config:
+        from_attributes = True
+
+
+class PermissionInfo(BaseModel):
+    """Schema for permission information."""
+
+    value: str
+    label: str
+
+
+class PermissionCategory(BaseModel):
+    """Schema for a category of permissions."""
+
+    name: str
+    permissions: list[PermissionInfo]
+
+
+class PermissionsListResponse(BaseModel):
+    """Schema for listing all permissions by category."""
+
+    categories: list[PermissionCategory]
+    all_permissions: list[str]
+
+
+# Update forward references
+GroupDetailResponse.model_rebuild()

+ 29 - 0
backend/app/schemas/library.py

@@ -262,3 +262,32 @@ class ZipExtractResponse(BaseModel):
     folders_created: int
     folders_created: int
     files: list[ZipExtractResult]
     files: list[ZipExtractResult]
     errors: list[ZipExtractError]
     errors: list[ZipExtractError]
+
+
+# ============ STL Thumbnail Generation ============
+
+
+class BatchThumbnailRequest(BaseModel):
+    """Schema for batch STL thumbnail generation request."""
+
+    file_ids: list[int] | None = None
+    folder_id: int | None = None
+    all_missing: bool = False
+
+
+class BatchThumbnailResult(BaseModel):
+    """Result for a single file thumbnail generation."""
+
+    file_id: int
+    filename: str
+    success: bool
+    error: str | None = None
+
+
+class BatchThumbnailResponse(BaseModel):
+    """Schema for batch thumbnail generation response."""
+
+    processed: int
+    succeeded: int
+    failed: int
+    results: list[BatchThumbnailResult]

+ 58 - 6
backend/app/schemas/smart_plug.py

@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator
 
 
 class SmartPlugBase(BaseModel):
 class SmartPlugBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     name: str = Field(..., min_length=1, max_length=100)
-    plug_type: Literal["tasmota", "homeassistant"] = "tasmota"
+    plug_type: Literal["tasmota", "homeassistant", "mqtt"] = "tasmota"
 
 
     # Tasmota fields (required when plug_type="tasmota")
     # Tasmota fields (required when plug_type="tasmota")
     ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
     ip_address: str | None = Field(default=None, pattern=r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$")
@@ -14,12 +14,36 @@ class SmartPlugBase(BaseModel):
     password: str | None = None
     password: str | None = None
 
 
     # Home Assistant fields (required when plug_type="homeassistant")
     # Home Assistant fields (required when plug_type="homeassistant")
-    ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$")
+    ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$")
     # Home Assistant energy sensor entities (optional, for separate energy sensors)
     # Home Assistant energy sensor entities (optional, for separate energy sensors)
     ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
     ha_energy_total_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$")
 
 
+    # MQTT fields (required when plug_type="mqtt")
+    # Legacy field - kept for backward compatibility
+    mqtt_topic: str | None = Field(default=None, max_length=200)  # Deprecated, use mqtt_power_topic
+
+    # Power monitoring
+    mqtt_power_topic: str | None = Field(default=None, max_length=200)  # Topic for power data
+    mqtt_power_path: str | None = Field(default=None, max_length=100)  # e.g., "power_l1" or "data.power"
+    mqtt_power_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion for power
+
+    # Energy monitoring
+    mqtt_energy_topic: str | None = Field(default=None, max_length=200)  # Topic for energy data
+    mqtt_energy_path: str | None = Field(default=None, max_length=100)  # e.g., "energy_l1"
+    mqtt_energy_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Unit conversion for energy
+
+    # State monitoring
+    mqtt_state_topic: str | None = Field(default=None, max_length=200)  # Topic for state data
+    mqtt_state_path: str | None = Field(default=None, max_length=100)  # e.g., "state_l1" for ON/OFF
+    mqtt_state_on_value: str | None = Field(
+        default=None, max_length=50
+    )  # What value means "ON" (e.g., "ON", "true", "1")
+
+    # Legacy multiplier - kept for backward compatibility
+    mqtt_multiplier: float = Field(default=1.0, ge=0.0001, le=10000)  # Deprecated, use mqtt_power_multiplier
+
     printer_id: int | None = None
     printer_id: int | None = None
     enabled: bool = True
     enabled: bool = True
     auto_on: bool = True
     auto_on: bool = True
@@ -35,8 +59,9 @@ class SmartPlugBase(BaseModel):
     schedule_enabled: bool = False
     schedule_enabled: bool = False
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")  # HH:MM format
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool = False
     show_in_switchbar: bool = False
+    show_on_printer_card: bool = True  # For scripts: show on printer card
 
 
     @model_validator(mode="after")
     @model_validator(mode="after")
     def validate_plug_type_fields(self) -> "SmartPlugBase":
     def validate_plug_type_fields(self) -> "SmartPlugBase":
@@ -44,6 +69,17 @@ class SmartPlugBase(BaseModel):
             raise ValueError("ip_address is required for Tasmota plugs")
             raise ValueError("ip_address is required for Tasmota plugs")
         if self.plug_type == "homeassistant" and not self.ha_entity_id:
         if self.plug_type == "homeassistant" and not self.ha_entity_id:
             raise ValueError("ha_entity_id is required for Home Assistant plugs")
             raise ValueError("ha_entity_id is required for Home Assistant plugs")
+        if self.plug_type == "mqtt":
+            # Determine the effective power topic (new field takes priority, fall back to legacy)
+            power_topic = self.mqtt_power_topic or self.mqtt_topic
+            # Path is optional - if not set, raw MQTT payload value will be used
+            has_power = bool(power_topic)
+            has_energy = bool(self.mqtt_energy_topic)
+            has_state = bool(self.mqtt_state_topic)
+
+            # At least one data source must be configured (path is optional)
+            if not has_power and not has_energy and not has_state:
+                raise ValueError("At least one MQTT topic must be configured for power, energy, or state monitoring")
         return self
         return self
 
 
 
 
@@ -53,13 +89,28 @@ class SmartPlugCreate(SmartPlugBase):
 
 
 class SmartPlugUpdate(BaseModel):
 class SmartPlugUpdate(BaseModel):
     name: str | None = None
     name: str | None = None
-    plug_type: Literal["tasmota", "homeassistant"] | None = None
+    plug_type: Literal["tasmota", "homeassistant", "mqtt"] | None = None
     ip_address: str | None = None
     ip_address: str | None = None
     ha_entity_id: str | None = None
     ha_entity_id: str | None = None
     # Home Assistant energy sensor entities (optional)
     # Home Assistant energy sensor entities (optional)
     ha_power_entity: str | None = None
     ha_power_entity: str | None = None
     ha_energy_today_entity: str | None = None
     ha_energy_today_entity: str | None = None
     ha_energy_total_entity: str | None = None
     ha_energy_total_entity: str | None = None
+    # MQTT fields (legacy)
+    mqtt_topic: str | None = None
+    mqtt_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    # MQTT power fields
+    mqtt_power_topic: str | None = None
+    mqtt_power_path: str | None = None
+    mqtt_power_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    # MQTT energy fields
+    mqtt_energy_topic: str | None = None
+    mqtt_energy_path: str | None = None
+    mqtt_energy_multiplier: float | None = Field(default=None, ge=0.0001, le=10000)
+    # MQTT state fields
+    mqtt_state_topic: str | None = None
+    mqtt_state_path: str | None = None
+    mqtt_state_on_value: str | None = None
     printer_id: int | None = None
     printer_id: int | None = None
     enabled: bool | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
     auto_on: bool | None = None
@@ -77,8 +128,9 @@ class SmartPlugUpdate(BaseModel):
     schedule_enabled: bool | None = None
     schedule_enabled: bool | None = None
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
     schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$")
-    # Switchbar visibility
+    # Visibility options
     show_in_switchbar: bool | None = None
     show_in_switchbar: bool | None = None
+    show_on_printer_card: bool | None = None
 
 
 
 
 class SmartPlugResponse(SmartPlugBase):
 class SmartPlugResponse(SmartPlugBase):
@@ -147,7 +199,7 @@ class HAEntity(BaseModel):
     entity_id: str
     entity_id: str
     friendly_name: str
     friendly_name: str
     state: str | None = None
     state: str | None = None
-    domain: str  # "switch", "light", "input_boolean"
+    domain: str  # "switch", "light", "input_boolean", "script"
 
 
 
 
 class HASensorEntity(BaseModel):
 class HASensorEntity(BaseModel):

+ 1 - 1
backend/app/services/homeassistant.py

@@ -228,7 +228,7 @@ class HomeAssistantService:
             - domain: str
             - domain: str
         """
         """
         # Default domains for smart plug control
         # Default domains for smart plug control
-        default_domains = {"switch", "light", "input_boolean"}
+        default_domains = {"switch", "light", "input_boolean", "script"}
 
 
         try:
         try:
             async with httpx.AsyncClient(timeout=self.timeout) as client:
             async with httpx.AsyncClient(timeout=self.timeout) as client:

+ 32 - 1
backend/app/services/mqtt_relay.py

@@ -34,6 +34,8 @@ class MQTTRelayService:
         self._broker = ""
         self._broker = ""
         self._port = 1883
         self._port = 1883
         self._last_printer_status: dict[int, float] = {}  # printer_id -> last publish timestamp
         self._last_printer_status: dict[int, float] = {}  # printer_id -> last publish timestamp
+        self._smart_plug_service = None  # Lazy import to avoid circular dependency
+        self._settings: dict = {}  # Store settings for smart plug service
 
 
     async def configure(self, settings: dict) -> bool:
     async def configure(self, settings: dict) -> bool:
         """Configure MQTT connection from settings.
         """Configure MQTT connection from settings.
@@ -41,9 +43,12 @@ class MQTTRelayService:
         Returns True if connection was successful or MQTT is disabled.
         Returns True if connection was successful or MQTT is disabled.
         """
         """
         self.enabled = settings.get("mqtt_enabled", False)
         self.enabled = settings.get("mqtt_enabled", False)
+        self._settings = settings  # Store for smart plug service
 
 
         if not self.enabled:
         if not self.enabled:
             await self.disconnect()
             await self.disconnect()
+            # Also configure smart plug service (will disable it)
+            await self._configure_smart_plug_service(settings)
             logger.info("MQTT relay disabled")
             logger.info("MQTT relay disabled")
             return True
             return True
 
 
@@ -67,7 +72,33 @@ class MQTTRelayService:
             await self.disconnect()
             await self.disconnect()
 
 
         # Create and connect client
         # Create and connect client
-        return await self._connect(broker, port, username, password, use_tls)
+        result = await self._connect(broker, port, username, password, use_tls)
+
+        # Configure smart plug service with same settings
+        await self._configure_smart_plug_service(settings)
+
+        return result
+
+    async def _configure_smart_plug_service(self, settings: dict):
+        """Configure the MQTT smart plug service with the same broker settings."""
+        try:
+            if self._smart_plug_service is None:
+                from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
+
+                self._smart_plug_service = mqtt_smart_plug_service
+
+            await self._smart_plug_service.configure(settings)
+        except Exception as e:
+            logger.error(f"Failed to configure MQTT smart plug service: {e}")
+
+    @property
+    def smart_plug_service(self):
+        """Get the MQTT smart plug service instance."""
+        if self._smart_plug_service is None:
+            from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
+
+            self._smart_plug_service = mqtt_smart_plug_service
+        return self._smart_plug_service
 
 
     async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool:
     async def _connect(self, broker: str, port: int, username: str, password: str, use_tls: bool) -> bool:
         """Establish MQTT connection."""
         """Establish MQTT connection."""

+ 488 - 0
backend/app/services/mqtt_smart_plug.py

@@ -0,0 +1,488 @@
+"""MQTT Smart Plug Service for subscribing to external MQTT topics and extracting power/energy data.
+
+This service enables integration with Shelly, Zigbee2MQTT, and other MQTT-based energy monitoring devices.
+"""
+
+import json
+import logging
+import threading
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+from typing import Any
+
+import paho.mqtt.client as mqtt
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class SmartPlugMQTTData:
+    """Latest data received from an MQTT smart plug."""
+
+    plug_id: int
+    power: float | None = None  # Current power in watts
+    energy: float | None = None  # Energy in kWh (today)
+    state: str | None = None  # "ON" or "OFF"
+    last_seen: datetime = field(default_factory=datetime.utcnow)
+
+
+@dataclass
+class MQTTDataSourceConfig:
+    """Configuration for a single MQTT data source (power, energy, or state)."""
+
+    topic: str
+    path: str
+    multiplier: float = 1.0  # For power/energy
+    on_value: str | None = None  # For state (what value means "ON")
+
+
+class MQTTSmartPlugService:
+    """Subscribes to MQTT topics for smart plug energy monitoring."""
+
+    # Consider plug unreachable if no message received in this time
+    REACHABLE_TIMEOUT_MINUTES = 5
+
+    def __init__(self):
+        self.client: mqtt.Client | None = None
+        self.connected = False
+        self._lock = threading.Lock()
+        # topic -> list of (plug_id, data_type) where data_type is "power", "energy", or "state"
+        self.subscriptions: dict[str, list[tuple[int, str]]] = {}
+        # plug_id -> {data_type: MQTTDataSourceConfig}
+        self.plug_configs: dict[int, dict[str, MQTTDataSourceConfig]] = {}
+        # plug_id -> latest data
+        self.plug_data: dict[int, SmartPlugMQTTData] = {}
+        self._configured = False
+        self._broker = ""
+        self._port = 1883
+        self._username = ""
+        self._password = ""
+        self._use_tls = False
+
+    def is_configured(self) -> bool:
+        """Check if the MQTT service is configured and connected."""
+        return self._configured and self.connected
+
+    def has_broker_settings(self) -> bool:
+        """Check if broker settings are available (even if not connected yet)."""
+        return bool(self._broker)
+
+    async def configure(self, settings: dict) -> bool:
+        """Configure MQTT connection from settings.
+
+        Uses the same broker settings as the MQTT relay service.
+        Returns True if connection was successful or MQTT is disabled.
+        """
+        enabled = settings.get("mqtt_enabled", False)
+
+        if not enabled:
+            await self.disconnect()
+            self._configured = False
+            logger.debug("MQTT smart plug service disabled (MQTT relay not enabled)")
+            return True
+
+        broker = settings.get("mqtt_broker", "")
+        port = settings.get("mqtt_port", 1883)
+        username = settings.get("mqtt_username", "")
+        password = settings.get("mqtt_password", "")
+        use_tls = settings.get("mqtt_use_tls", False)
+
+        if not broker:
+            logger.warning("MQTT smart plug service: no broker configured")
+            self._configured = False
+            return False
+
+        # Check if settings changed
+        settings_changed = (
+            self._broker != broker
+            or self._port != port
+            or self._username != username
+            or self._password != password
+            or self._use_tls != use_tls
+        )
+
+        self._broker = broker
+        self._port = port
+        self._username = username
+        self._password = password
+        self._use_tls = use_tls
+        self._configured = True
+
+        # Disconnect and reconnect if settings changed
+        if settings_changed and self.client:
+            await self.disconnect()
+
+        # Connect if not already connected
+        if not self.client or not self.connected:
+            return await self._connect()
+
+        return True
+
+    async def _connect(self) -> bool:
+        """Establish MQTT connection."""
+        import asyncio
+        import ssl
+
+        try:
+            # Create client with callback API version 2
+            self.client = mqtt.Client(
+                callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
+                client_id=f"bambuddy-smartplug-{id(self)}",
+                protocol=mqtt.MQTTv311,
+            )
+
+            # Set up callbacks
+            self.client.on_connect = self._on_connect
+            self.client.on_disconnect = self._on_disconnect
+            self.client.on_message = self._on_message
+
+            # Configure authentication
+            if self._username:
+                self.client.username_pw_set(self._username, self._password)
+
+            # Configure TLS
+            if self._use_tls:
+                self.client.tls_set(cert_reqs=ssl.CERT_NONE)
+                self.client.tls_insecure_set(True)
+
+            # Connect with timeout
+            try:
+                await asyncio.wait_for(
+                    asyncio.to_thread(self.client.connect_async, self._broker, self._port, 60),
+                    timeout=3.0,
+                )
+            except TimeoutError:
+                logger.warning(f"MQTT smart plug connection to {self._broker}:{self._port} timed out")
+                return False
+
+            self.client.loop_start()
+
+            # Wait briefly for connection
+            await asyncio.sleep(1.0)
+
+            if self.connected:
+                logger.info(f"MQTT smart plug service connected to {self._broker}:{self._port}")
+                # Resubscribe to all topics
+                self._resubscribe_all()
+                return True
+            else:
+                logger.warning(f"MQTT smart plug connection pending to {self._broker}:{self._port}")
+                return True  # Connection is async
+
+        except Exception as e:
+            logger.error(f"MQTT smart plug connection failed: {e}")
+            self.connected = False
+            return False
+
+    def _on_connect(
+        self,
+        client: mqtt.Client,
+        userdata: Any,
+        flags: dict,
+        reason_code: int | mqtt.ReasonCode,
+        properties: mqtt.Properties | None = None,
+    ):
+        """Callback when connected to broker."""
+        rc = reason_code if isinstance(reason_code, int) else reason_code.value
+        if rc == 0:
+            self.connected = True
+            logger.info("MQTT smart plug service connected successfully")
+            # Resubscribe to all topics
+            self._resubscribe_all()
+        else:
+            self.connected = False
+            logger.error(f"MQTT smart plug connection failed: {reason_code}")
+
+    def _on_disconnect(
+        self,
+        client: mqtt.Client,
+        userdata: Any,
+        flags_or_rc: dict | int | mqtt.ReasonCode,
+        reason_code: int | mqtt.ReasonCode | None = None,
+        properties: mqtt.Properties | None = None,
+    ):
+        """Callback when disconnected from broker."""
+        self.connected = False
+        rc = reason_code if reason_code is not None else flags_or_rc
+        rc_val = rc if isinstance(rc, int) else getattr(rc, "value", 0)
+        if rc_val != 0:
+            logger.warning(f"MQTT smart plug service disconnected: {rc}")
+        else:
+            logger.info("MQTT smart plug service disconnected cleanly")
+
+    def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage):
+        """Handle incoming MQTT message, extract data using JSON path."""
+        topic = msg.topic
+
+        with self._lock:
+            subscriptions = self.subscriptions.get(topic, [])
+            if not subscriptions:
+                return
+
+            # Parse JSON payload (or treat as raw value)
+            try:
+                payload = json.loads(msg.payload.decode("utf-8"))
+                is_json = True
+            except (json.JSONDecodeError, UnicodeDecodeError):
+                # Not JSON - treat the whole payload as a raw value
+                payload = msg.payload.decode("utf-8").strip()
+                is_json = False
+
+            # Process for each subscribed (plug_id, data_type)
+            for plug_id, data_type in subscriptions:
+                configs = self.plug_configs.get(plug_id, {})
+                config = configs.get(data_type)
+                if not config:
+                    continue
+
+                # Extract value using path (or use raw payload if no path)
+                if is_json and config.path:
+                    raw_value = self._extract_json_path(payload, config.path)
+                elif is_json and not config.path:
+                    # JSON but no path - if it's a simple value use it, otherwise skip
+                    if isinstance(payload, (int, float, str, bool)):
+                        raw_value = payload
+                    else:
+                        # Can't use a dict/list as a value
+                        logger.debug(f"MQTT plug {plug_id}: JSON payload is object/array but no path configured")
+                        continue
+                else:
+                    # Raw value (non-JSON)
+                    raw_value = payload
+
+                if raw_value is None:
+                    continue
+
+                # Initialize plug data if needed
+                if plug_id not in self.plug_data:
+                    self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
+
+                data = self.plug_data[plug_id]
+                data.last_seen = datetime.utcnow()
+
+                # Process based on data type
+                if data_type == "power":
+                    try:
+                        data.power = float(raw_value) * config.multiplier
+                        logger.debug(f"MQTT smart plug {plug_id}: power={data.power}")
+                    except (ValueError, TypeError):
+                        pass
+
+                elif data_type == "energy":
+                    try:
+                        data.energy = float(raw_value) * config.multiplier
+                        logger.debug(f"MQTT smart plug {plug_id}: energy={data.energy}")
+                    except (ValueError, TypeError):
+                        pass
+
+                elif data_type == "state":
+                    state_str = str(raw_value)
+                    # Check against configured ON value if set
+                    if config.on_value:
+                        # Case-insensitive comparison
+                        if state_str.lower() == config.on_value.lower():
+                            data.state = "ON"
+                        else:
+                            data.state = "OFF"
+                    else:
+                        # Default behavior: normalize common values
+                        upper_state = state_str.upper()
+                        if upper_state in ("ON", "1", "TRUE"):
+                            data.state = "ON"
+                        elif upper_state in ("OFF", "0", "FALSE"):
+                            data.state = "OFF"
+                        else:
+                            data.state = state_str
+                    logger.debug(f"MQTT smart plug {plug_id}: state={data.state}")
+
+    def _extract_json_path(self, data: dict, path: str) -> Any:
+        """Extract value using dot notation (e.g., 'power_l1' or 'data.power').
+
+        Supports simple dot notation for nested objects.
+        """
+        if not path:
+            return None
+
+        parts = path.split(".")
+        current = data
+
+        for part in parts:
+            if isinstance(current, dict) and part in current:
+                current = current[part]
+            else:
+                return None
+
+        return current
+
+    def _resubscribe_all(self):
+        """Resubscribe to all registered topics after reconnection."""
+        if not self.client or not self.connected:
+            return
+
+        with self._lock:
+            for topic in self.subscriptions:
+                if self.subscriptions[topic]:  # Only if there are subscribers
+                    try:
+                        self.client.subscribe(topic, qos=1)
+                        logger.debug(f"MQTT smart plug: resubscribed to {topic}")
+                    except Exception as e:
+                        logger.error(f"MQTT smart plug: failed to resubscribe to {topic}: {e}")
+
+    def subscribe(
+        self,
+        plug_id: int,
+        # Power source
+        power_topic: str | None = None,
+        power_path: str | None = None,
+        power_multiplier: float = 1.0,
+        # Energy source
+        energy_topic: str | None = None,
+        energy_path: str | None = None,
+        energy_multiplier: float = 1.0,
+        # State source
+        state_topic: str | None = None,
+        state_path: str | None = None,
+        state_on_value: str | None = None,
+        # Legacy: single topic/path/multiplier (for backward compatibility)
+        topic: str | None = None,
+        multiplier: float = 1.0,
+    ):
+        """Subscribe to MQTT topics for a plug.
+
+        Each data type (power, energy, state) can have its own topic.
+        For backward compatibility, if power_topic is not set but topic is,
+        topic will be used for all data types that have paths configured.
+        """
+        with self._lock:
+            # Initialize config for this plug
+            self.plug_configs[plug_id] = {}
+
+            # Determine topics (new fields take priority, fall back to legacy)
+            effective_power_topic = power_topic or topic
+            effective_energy_topic = energy_topic or topic
+            effective_state_topic = state_topic or topic
+
+            # Use new multipliers or fall back to legacy
+            effective_power_mult = power_multiplier if power_multiplier != 1.0 else multiplier
+            effective_energy_mult = energy_multiplier if energy_multiplier != 1.0 else multiplier
+
+            # Configure power subscription (path is optional - empty means use raw payload)
+            if effective_power_topic:
+                config = MQTTDataSourceConfig(
+                    topic=effective_power_topic,
+                    path=power_path or "",
+                    multiplier=effective_power_mult,
+                )
+                self.plug_configs[plug_id]["power"] = config
+                self._add_subscription(plug_id, effective_power_topic, "power")
+
+            # Configure energy subscription (path is optional - empty means use raw payload)
+            if effective_energy_topic:
+                config = MQTTDataSourceConfig(
+                    topic=effective_energy_topic,
+                    path=energy_path or "",
+                    multiplier=effective_energy_mult,
+                )
+                self.plug_configs[plug_id]["energy"] = config
+                self._add_subscription(plug_id, effective_energy_topic, "energy")
+
+            # Configure state subscription (path is optional - empty means use raw payload)
+            if effective_state_topic:
+                config = MQTTDataSourceConfig(
+                    topic=effective_state_topic,
+                    path=state_path or "",
+                    on_value=state_on_value,
+                )
+                self.plug_configs[plug_id]["state"] = config
+                self._add_subscription(plug_id, effective_state_topic, "state")
+
+            # Initialize data entry
+            if plug_id not in self.plug_data:
+                self.plug_data[plug_id] = SmartPlugMQTTData(plug_id=plug_id)
+
+            logger.info(
+                f"MQTT smart plug {plug_id}: configured with "
+                f"power={effective_power_topic if power_path else None}, "
+                f"energy={effective_energy_topic if energy_path else None}, "
+                f"state={effective_state_topic if state_path else None}"
+            )
+
+    def _add_subscription(self, plug_id: int, topic: str, data_type: str):
+        """Add a subscription for a plug/data_type to a topic."""
+        if topic not in self.subscriptions:
+            self.subscriptions[topic] = []
+            # Actually subscribe if connected
+            if self.client and self.connected:
+                try:
+                    self.client.subscribe(topic, qos=1)
+                    logger.info(f"MQTT smart plug: subscribed to {topic}")
+                except Exception as e:
+                    logger.error(f"MQTT smart plug: failed to subscribe to {topic}: {e}")
+
+        entry = (plug_id, data_type)
+        if entry not in self.subscriptions[topic]:
+            self.subscriptions[topic].append(entry)
+
+    def unsubscribe(self, plug_id: int):
+        """Unsubscribe when plug is deleted/updated."""
+        with self._lock:
+            # Get all configs for this plug
+            configs = self.plug_configs.pop(plug_id, {})
+            if not configs:
+                # Still clean up any stray subscriptions
+                pass
+
+            # Collect all topics this plug was subscribed to
+            topics_to_check = set()
+            for _data_type, config in configs.items():
+                topics_to_check.add(config.topic)
+
+            # Also scan subscriptions to remove any entries for this plug
+            for topic in list(self.subscriptions.keys()):
+                # Remove all entries for this plug_id
+                self.subscriptions[topic] = [(pid, dtype) for pid, dtype in self.subscriptions[topic] if pid != plug_id]
+                topics_to_check.add(topic)
+
+            # Unsubscribe from topics with no more subscribers
+            for topic in topics_to_check:
+                if topic in self.subscriptions and not self.subscriptions[topic]:
+                    del self.subscriptions[topic]
+                    if self.client and self.connected:
+                        try:
+                            self.client.unsubscribe(topic)
+                            logger.info(f"MQTT smart plug: unsubscribed from {topic}")
+                        except Exception as e:
+                            logger.error(f"MQTT smart plug: failed to unsubscribe from {topic}: {e}")
+
+            # Remove data
+            self.plug_data.pop(plug_id, None)
+
+    def get_plug_data(self, plug_id: int) -> SmartPlugMQTTData | None:
+        """Get latest data for a plug (called by status endpoint)."""
+        with self._lock:
+            return self.plug_data.get(plug_id)
+
+    def is_reachable(self, plug_id: int) -> bool:
+        """Check if a plug has received data recently."""
+        data = self.get_plug_data(plug_id)
+        if not data:
+            return False
+
+        timeout = timedelta(minutes=self.REACHABLE_TIMEOUT_MINUTES)
+        return datetime.utcnow() - data.last_seen < timeout
+
+    async def disconnect(self):
+        """Disconnect from MQTT broker."""
+        if self.client:
+            try:
+                self.client.loop_stop()
+                self.client.disconnect()
+            except Exception as e:
+                logger.debug(f"MQTT smart plug disconnect error (ignored): {e}")
+            finally:
+                self.client = None
+                self.connected = False
+
+
+# Global instance
+mqtt_smart_plug_service = MQTTSmartPlugService()

+ 8 - 4
backend/app/services/print_scheduler.py

@@ -21,7 +21,7 @@ from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
 from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
-from backend.app.services.tasmota import tasmota_service
+from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.printer_models import normalize_printer_model
 from backend.app.utils.printer_models import normalize_printer_model
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -649,15 +649,18 @@ class PrintScheduler:
 
 
         Returns True if printer connected successfully within timeout.
         Returns True if printer connected successfully within timeout.
         """
         """
+        # Get the appropriate service for the plug type (Tasmota or Home Assistant)
+        service = await smart_plug_manager.get_service_for_plug(plug, db)
+
         # Check current plug state
         # Check current plug state
-        status = await tasmota_service.get_status(plug)
+        status = await service.get_status(plug)
         if not status.get("reachable"):
         if not status.get("reachable"):
             logger.warning(f"Smart plug '{plug.name}' is not reachable")
             logger.warning(f"Smart plug '{plug.name}' is not reachable")
             return False
             return False
 
 
         # Turn on if not already on
         # Turn on if not already on
         if status.get("state") != "ON":
         if status.get("state") != "ON":
-            success = await tasmota_service.turn_on(plug)
+            success = await service.turn_on(plug)
             if not success:
             if not success:
                 logger.warning(f"Failed to turn on smart plug '{plug.name}'")
                 logger.warning(f"Failed to turn on smart plug '{plug.name}'")
                 return False
                 return False
@@ -726,7 +729,8 @@ class PrintScheduler:
             # Wait for cooldown (up to 10 minutes)
             # Wait for cooldown (up to 10 minutes)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
             await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600)
             logger.info(f"Auto-off: Powering off printer {item.printer_id}")
             logger.info(f"Auto-off: Powering off printer {item.printer_id}")
-            await tasmota_service.turn_off(plug)
+            service = await smart_plug_manager.get_service_for_plug(plug, db)
+            await service.turn_off(plug)
 
 
     async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
     async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str:
         """Get a human-readable name for a queue item."""
         """Get a human-readable name for a queue item."""

+ 11 - 6
backend/app/services/smart_plug_manager.py

@@ -27,7 +27,7 @@ class SmartPlugManager:
         self._scheduler_task: asyncio.Task | None = None
         self._scheduler_task: asyncio.Task | None = None
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
         self._last_schedule_check: dict[int, str] = {}  # plug_id -> "HH:MM" last executed
 
 
-    async def _get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
+    async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None):
         """Get the appropriate service for the plug type.
         """Get the appropriate service for the plug type.
 
 
         For HA plugs, configures the service with current settings from DB.
         For HA plugs, configures the service with current settings from DB.
@@ -110,7 +110,7 @@ class SmartPlugManager:
             plugs = result.scalars().all()
             plugs = result.scalars().all()
 
 
             for plug in plugs:
             for plug in plugs:
-                service = await self._get_service_for_plug(plug, db)
+                service = await self.get_service_for_plug(plug, db)
 
 
                 # Check if we should turn on
                 # Check if we should turn on
                 if plug.schedule_on_time == current_time:
                 if plug.schedule_on_time == current_time:
@@ -166,7 +166,7 @@ class SmartPlugManager:
 
 
         # Turn on the plug
         # Turn on the plug
         logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
         logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'")
-        service = await self._get_service_for_plug(plug, db)
+        service = await self.get_service_for_plug(plug, db)
         success = await service.turn_on(plug)
         success = await service.turn_on(plug)
 
 
         if success:
         if success:
@@ -195,6 +195,11 @@ class SmartPlugManager:
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             logger.debug(f"Smart plug '{plug.name}' auto_off is disabled")
             return
             return
 
 
+        # Skip auto-off for HA script entities (scripts can only be triggered, not turned off)
+        if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
+            logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off")
+            return
+
         # Only auto-off on successful completion, not on failures
         # Only auto-off on successful completion, not on failures
         # This allows the user to investigate errors before power-off
         # This allows the user to investigate errors before power-off
         if status != "completed":
         if status != "completed":
@@ -261,7 +266,7 @@ class SmartPlugManager:
                     self.name = f"plug_{plug_id}"
                     self.name = f"plug_{plug_id}"
 
 
             plug_info = PlugInfo()
             plug_info = PlugInfo()
-            service = await self._get_service_for_plug(plug_info)
+            service = await self.get_service_for_plug(plug_info)
             success = await service.turn_off(plug_info)
             success = await service.turn_off(plug_info)
             logger.info(f"Turned off plug {plug_id} after time delay")
             logger.info(f"Turned off plug {plug_id} after time delay")
 
 
@@ -353,7 +358,7 @@ class SmartPlugManager:
                                 self.name = f"plug_{plug_id}"
                                 self.name = f"plug_{plug_id}"
 
 
                         plug_info = PlugInfo()
                         plug_info = PlugInfo()
-                        service = await self._get_service_for_plug(plug_info)
+                        service = await self.get_service_for_plug(plug_info)
                         success = await service.turn_off(plug_info)
                         success = await service.turn_off(plug_info)
                         logger.info(
                         logger.info(
                             f"Turned off plug {plug_id} after nozzle temp dropped to "
                             f"Turned off plug {plug_id} after nozzle temp dropped to "
@@ -474,7 +479,7 @@ class SmartPlugManager:
                         # For time mode, just turn off immediately since delay already passed
                         # For time mode, just turn off immediately since delay already passed
                         logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
                         logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
 
 
-                        service = await self._get_service_for_plug(plug, db)
+                        service = await self.get_service_for_plug(plug, db)
                         success = await service.turn_off(plug)
                         success = await service.turn_off(plug)
                         if success:
                         if success:
                             await self._mark_auto_off_executed(plug.id)
                             await self._mark_auto_off_executed(plug.id)

+ 140 - 0
backend/app/services/stl_thumbnail.py

@@ -0,0 +1,140 @@
+"""STL Thumbnail Generation Service.
+
+Generates thumbnail images from STL files using trimesh and matplotlib.
+"""
+
+import logging
+import uuid
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+# Bambu green color for rendering
+BAMBU_GREEN = "#00AE42"
+BACKGROUND_COLOR = "#1a1a1a"
+
+# Maximum vertices before simplification
+MAX_VERTICES = 100000
+
+
+def generate_stl_thumbnail(
+    stl_path: Path,
+    thumbnails_dir: Path,
+    size: int = 256,
+) -> str | None:
+    """Generate a thumbnail image from an STL file.
+
+    Args:
+        stl_path: Path to the STL file
+        thumbnails_dir: Directory to save the thumbnail
+        size: Thumbnail size in pixels (default 256x256)
+
+    Returns:
+        Path to the generated thumbnail, or None on failure
+    """
+    try:
+        import matplotlib
+        import trimesh
+
+        # Use Agg backend for headless rendering
+        matplotlib.use("Agg")
+        import matplotlib.pyplot as plt
+        from mpl_toolkits.mplot3d import Axes3D  # noqa: F401
+        from mpl_toolkits.mplot3d.art3d import Poly3DCollection
+
+        # Load the STL file
+        mesh = trimesh.load(str(stl_path), force="mesh")
+
+        if mesh is None or not hasattr(mesh, "vertices") or len(mesh.vertices) == 0:
+            logger.warning(f"Failed to load STL or empty mesh: {stl_path}")
+            return None
+
+        # Simplify large meshes for performance
+        if len(mesh.vertices) > MAX_VERTICES:
+            logger.info(f"Simplifying mesh from {len(mesh.vertices)} vertices")
+            try:
+                # Calculate reduction ratio (0-1 range)
+                # e.g., 124633 vertices -> 100000 means keep ~80%, so reduce by ~20%
+                keep_ratio = MAX_VERTICES / len(mesh.vertices)
+                target_reduction = 1.0 - keep_ratio
+                # Clamp to valid range (0.01 to 0.99)
+                target_reduction = max(0.01, min(0.99, target_reduction))
+                mesh = mesh.simplify_quadric_decimation(target_reduction)
+                logger.info(f"Simplified mesh to {len(mesh.vertices)} vertices")
+            except Exception as e:
+                logger.warning(f"Mesh simplification failed, using original: {e}")
+
+        # Get mesh bounds and center it
+        vertices = mesh.vertices
+        bounds_min = vertices.min(axis=0)
+        bounds_max = vertices.max(axis=0)
+        center = (bounds_min + bounds_max) / 2
+        vertices_centered = vertices - center
+
+        # Scale to fit in view
+        max_extent = (bounds_max - bounds_min).max()
+        if max_extent > 0:
+            scale = 1.0 / max_extent
+            vertices_scaled = vertices_centered * scale
+        else:
+            vertices_scaled = vertices_centered
+
+        # Create figure with dark background
+        fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
+        fig.patch.set_facecolor(BACKGROUND_COLOR)
+
+        ax = fig.add_subplot(111, projection="3d")
+        ax.set_facecolor(BACKGROUND_COLOR)
+
+        # Create polygon collection from mesh faces
+        faces = mesh.faces
+        poly3d = [[vertices_scaled[vertex] for vertex in face] for face in faces]
+
+        collection = Poly3DCollection(
+            poly3d,
+            facecolors=BAMBU_GREEN,
+            edgecolors=BAMBU_GREEN,
+            linewidths=0.1,
+            alpha=0.9,
+        )
+        ax.add_collection3d(collection)
+
+        # Set axis limits
+        ax.set_xlim(-0.6, 0.6)
+        ax.set_ylim(-0.6, 0.6)
+        ax.set_zlim(-0.6, 0.6)
+
+        # Set view angle (isometric-ish)
+        ax.view_init(elev=25, azim=45)
+
+        # Remove axes and grid
+        ax.set_axis_off()
+        ax.grid(False)
+
+        # Remove margins
+        plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
+
+        # Save thumbnail
+        thumb_filename = f"{uuid.uuid4().hex}.png"
+        thumb_path = thumbnails_dir / thumb_filename
+
+        fig.savefig(
+            thumb_path,
+            format="png",
+            facecolor=BACKGROUND_COLOR,
+            edgecolor="none",
+            bbox_inches="tight",
+            pad_inches=0.05,
+            dpi=100,
+        )
+        plt.close(fig)
+
+        logger.info(f"Generated STL thumbnail: {thumb_path}")
+        return str(thumb_path)
+
+    except ImportError as e:
+        logger.warning(f"STL thumbnail generation unavailable (missing dependencies): {e}")
+        return None
+    except Exception as e:
+        logger.warning(f"Failed to generate STL thumbnail for {stl_path}: {e}")
+        return None

+ 40 - 0
backend/tests/conftest.py

@@ -67,6 +67,7 @@ async def test_engine():
         archive,
         archive,
         external_link,
         external_link,
         filament,
         filament,
+        group,
         kprofile_note,
         kprofile_note,
         maintenance,
         maintenance,
         notification,
         notification,
@@ -122,6 +123,11 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
         patch("backend.app.core.auth.async_session", test_async_session),
         patch("backend.app.core.auth.async_session", test_async_session),
         patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
         patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
     ):
     ):
+        # Seed default groups for tests that need them
+        from backend.app.core.database import seed_default_groups
+
+        await seed_default_groups()
+
         async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
         async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
             yield client
             yield client
 
 
@@ -216,6 +222,24 @@ def mock_mqtt_client():
         yield mock
         yield mock
 
 
 
 
+@pytest.fixture
+def mock_mqtt_smart_plug_service():
+    """Mock the MQTT smart plug service for MQTT plug tests."""
+    with patch("backend.app.api.routes.smart_plugs.mqtt_relay") as mock:
+        # Create a mock smart_plug_service
+        mock_service = MagicMock()
+        mock_service.is_configured = MagicMock(return_value=True)
+        mock_service.has_broker_settings = MagicMock(return_value=True)
+        mock_service.configure = AsyncMock(return_value=True)
+        mock_service.subscribe = MagicMock()
+        mock_service.unsubscribe = MagicMock()
+        mock_service.get_plug_data = MagicMock(return_value=None)
+        mock_service.is_reachable = MagicMock(return_value=False)
+
+        mock.smart_plug_service = mock_service
+        yield mock
+
+
 @pytest.fixture
 @pytest.fixture
 def mock_ftp_client():
 def mock_ftp_client():
     """Mock the FTP client for file transfer tests."""
     """Mock the FTP client for file transfer tests."""
@@ -296,6 +320,22 @@ def smart_plug_factory(db_session):
         if plug_type == "homeassistant":
         if plug_type == "homeassistant":
             defaults["ha_entity_id"] = "switch.test"
             defaults["ha_entity_id"] = "switch.test"
             defaults["ip_address"] = None
             defaults["ip_address"] = None
+        elif plug_type == "mqtt":
+            # Legacy fields (for backward compatibility tests)
+            defaults["mqtt_topic"] = kwargs.get("mqtt_topic", "test/topic")
+            defaults["mqtt_multiplier"] = kwargs.get("mqtt_multiplier", 1.0)
+            # New separate topic/path/multiplier fields
+            defaults["mqtt_power_topic"] = kwargs.get("mqtt_power_topic")
+            defaults["mqtt_power_path"] = kwargs.get("mqtt_power_path", "power")
+            defaults["mqtt_power_multiplier"] = kwargs.get("mqtt_power_multiplier", 1.0)
+            defaults["mqtt_energy_topic"] = kwargs.get("mqtt_energy_topic")
+            defaults["mqtt_energy_path"] = kwargs.get("mqtt_energy_path")
+            defaults["mqtt_energy_multiplier"] = kwargs.get("mqtt_energy_multiplier", 1.0)
+            defaults["mqtt_state_topic"] = kwargs.get("mqtt_state_topic")
+            defaults["mqtt_state_path"] = kwargs.get("mqtt_state_path")
+            defaults["mqtt_state_on_value"] = kwargs.get("mqtt_state_on_value")
+            defaults["ip_address"] = None
+            defaults["ha_entity_id"] = None
         else:
         else:
             defaults["ip_address"] = "192.168.1.100"
             defaults["ip_address"] = "192.168.1.100"
             defaults["ha_entity_id"] = None
             defaults["ha_entity_id"] = None

+ 124 - 0
backend/tests/integration/test_archives_api.py

@@ -434,3 +434,127 @@ class TestArchiveF3DEndpoints:
         """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
         """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
         response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
         response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
         assert response.status_code == 404
         assert response.status_code == 404
+
+    # ========================================================================
+    # Tag Management endpoints (Issue #183)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_empty(self, async_client: AsyncClient):
+        """Verify empty list when no tags exist."""
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify tags are returned with counts."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
+        await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
+        await archive_factory(printer.id, print_name="Archive 3", tags="test")
+
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+
+        # Convert to dict for easier lookup
+        tags_dict = {t["name"]: t["count"] for t in data}
+        assert tags_dict.get("functional") == 2
+        assert tags_dict.get("test") == 2
+        assert tags_dict.get("calibration") == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_tags_sorted_by_count(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify tags are sorted by count descending, then by name."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, tags="alpha")
+        await archive_factory(printer.id, tags="beta, alpha")
+        await archive_factory(printer.id, tags="gamma, beta, alpha")
+
+        response = await async_client.get("/api/v1/archives/tags")
+        assert response.status_code == 200
+        data = response.json()
+
+        # alpha=3, beta=2, gamma=1
+        assert data[0]["name"] == "alpha"
+        assert data[0]["count"] == 3
+        assert data[1]["name"] == "beta"
+        assert data[1]["count"] == 2
+        assert data[2]["name"] == "gamma"
+        assert data[2]["count"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify renaming a tag updates all archives."""
+        printer = await printer_factory()
+        a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
+        a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
+        await archive_factory(printer.id, print_name="Archive 3", tags="different")
+
+        response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
+        assert response.status_code == 200
+        data = response.json()
+        assert data["affected"] == 2
+
+        # Verify the archives were updated
+        response = await async_client.get(f"/api/v1/archives/{a1.id}")
+        assert "new-tag" in response.json()["tags"]
+        assert "old-tag" not in response.json()["tags"]
+
+        response = await async_client.get(f"/api/v1/archives/{a2.id}")
+        assert response.json()["tags"] == "new-tag"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag_no_change(self, async_client: AsyncClient):
+        """Verify renaming to same name returns 0 affected."""
+        response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
+        assert response.status_code == 200
+        assert response.json()["affected"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
+        """Verify renaming to empty name returns error."""
+        response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
+        """Verify deleting a tag removes it from all archives."""
+        printer = await printer_factory()
+        a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
+        a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
+        await archive_factory(printer.id, print_name="Archive 3", tags="different")
+
+        response = await async_client.delete("/api/v1/archives/tags/delete-me")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["affected"] == 2
+
+        # Verify the archives were updated
+        response = await async_client.get(f"/api/v1/archives/{a1.id}")
+        assert response.json()["tags"] == "keep"
+
+        response = await async_client.get(f"/api/v1/archives/{a2.id}")
+        # Should be None or empty when last tag is removed
+        assert response.json()["tags"] is None or response.json()["tags"] == ""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag_not_found(self, async_client: AsyncClient):
+        """Verify deleting non-existent tag returns 0 affected."""
+        response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
+        assert response.status_code == 200
+        assert response.json()["affected"] == 0

+ 330 - 1
backend/tests/integration/test_auth_api.py

@@ -205,7 +205,18 @@ class TestUsersAPI:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_list_users_requires_auth(self, async_client: AsyncClient):
     async def test_list_users_requires_auth(self, async_client: AsyncClient):
-        """Verify listing users requires authentication."""
+        """Verify listing users requires authentication when auth is enabled."""
+        # First enable auth
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "authreqadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        # Now try to list users without a token
         response = await async_client.get("/api/v1/users/")
         response = await async_client.get("/api/v1/users/")
 
 
         assert response.status_code == 401
         assert response.status_code == 401
@@ -360,3 +371,321 @@ class TestAuthDisableAPI:
         # Verify auth is now disabled
         # Verify auth is now disabled
         status_response = await async_client.get("/api/v1/auth/status")
         status_response = await async_client.get("/api/v1/auth/status")
         assert status_response.json()["auth_enabled"] is False
         assert status_response.json()["auth_enabled"] is False
+
+
+class TestGroupsAPI:
+    """Integration tests for /api/v1/groups/ endpoints."""
+
+    @pytest.fixture
+    async def auth_token(self, async_client: AsyncClient):
+        """Setup auth and return admin token."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "groupsadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "groupsadmin", "password": "adminpassword123"},
+        )
+        return login_response.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_groups(self, async_client: AsyncClient, auth_token: str):
+        """Verify listing groups returns default groups."""
+        response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 200
+        groups = response.json()
+        assert isinstance(groups, list)
+        # Should have default groups: Administrators, Operators, Viewers
+        group_names = [g["name"] for g in groups]
+        assert "Administrators" in group_names
+        assert "Operators" in group_names
+        assert "Viewers" in group_names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_permissions(self, async_client: AsyncClient, auth_token: str):
+        """Verify getting available permissions."""
+        response = await async_client.get(
+            "/api/v1/groups/permissions",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 200
+        permissions = response.json()
+        assert isinstance(permissions, dict)
+        # Should have permission categories
+        assert "Printers" in permissions or len(permissions) > 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify creating a new group."""
+        response = await async_client.post(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "name": "Custom Group",
+                "description": "A custom test group",
+                "permissions": ["printers:read", "archives:read"],
+            },
+        )
+
+        assert response.status_code == 201
+        group = response.json()
+        assert group["name"] == "Custom Group"
+        assert group["description"] == "A custom test group"
+        assert "printers:read" in group["permissions"]
+        assert group["is_system"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify updating a group."""
+        # Create a group first
+        create_response = await async_client.post(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "name": "Update Test Group",
+                "permissions": ["printers:read"],
+            },
+        )
+        group_id = create_response.json()["id"]
+
+        # Update the group
+        response = await async_client.patch(
+            f"/api/v1/groups/{group_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "description": "Updated description",
+                "permissions": ["printers:read", "printers:control"],
+            },
+        )
+
+        assert response.status_code == 200
+        group = response.json()
+        assert group["description"] == "Updated description"
+        assert "printers:control" in group["permissions"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cannot_delete_system_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify system groups cannot be deleted."""
+        # Get the Administrators group
+        list_response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        admin_group = next(g for g in list_response.json() if g["name"] == "Administrators")
+
+        # Try to delete it
+        response = await async_client.delete(
+            f"/api/v1/groups/{admin_group['id']}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 400
+        assert "system group" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_custom_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify custom groups can be deleted."""
+        # Create a group
+        create_response = await async_client.post(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={"name": "Delete Test Group"},
+        )
+        group_id = create_response.json()["id"]
+
+        # Delete it
+        response = await async_client.delete(
+            f"/api/v1/groups/{group_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 204
+
+
+class TestUserGroupsAPI:
+    """Integration tests for user-group assignments."""
+
+    @pytest.fixture
+    async def auth_token(self, async_client: AsyncClient):
+        """Setup auth and return admin token."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "usergroupadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "usergroupadmin", "password": "adminpassword123"},
+        )
+        return login_response.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user_with_groups(self, async_client: AsyncClient, auth_token: str):
+        """Verify creating a user with group assignments."""
+        # Get Operators group ID
+        groups_response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        operators_group = next(g for g in groups_response.json() if g["name"] == "Operators")
+
+        # Create user with group
+        response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "groupuser",
+                "password": "password123",
+                "group_ids": [operators_group["id"]],
+            },
+        )
+
+        assert response.status_code == 201
+        user = response.json()
+        assert any(g["name"] == "Operators" for g in user["groups"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_user_to_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify adding a user to a group."""
+        # Create a user
+        user_response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={"username": "addtogroup", "password": "password123"},
+        )
+        user_id = user_response.json()["id"]
+
+        # Get Viewers group
+        groups_response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        viewers_group = next(g for g in groups_response.json() if g["name"] == "Viewers")
+
+        # Add user to group
+        response = await async_client.post(
+            f"/api/v1/groups/{viewers_group['id']}/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 204
+
+        # Verify user is in group
+        user_check = await async_client.get(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        assert any(g["name"] == "Viewers" for g in user_check.json()["groups"])
+
+
+class TestChangePasswordAPI:
+    """Integration tests for /api/v1/users/me/change-password endpoint."""
+
+    @pytest.fixture
+    async def user_token(self, async_client: AsyncClient):
+        """Setup auth and return regular user token."""
+        # Enable auth with admin
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "pwchangeadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        admin_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "pwchangeadmin", "password": "adminpassword123"},
+        )
+        admin_token = admin_login.json()["access_token"]
+
+        # Create a regular user
+        await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {admin_token}"},
+            json={"username": "pwchangeuser", "password": "oldpassword123"},
+        )
+
+        # Login as regular user
+        user_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "pwchangeuser", "password": "oldpassword123"},
+        )
+        return user_login.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_change_password_success(self, async_client: AsyncClient, user_token: str):
+        """Verify user can change their own password."""
+        response = await async_client.post(
+            "/api/v1/users/me/change-password",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={
+                "current_password": "oldpassword123",
+                "new_password": "newpassword456",
+            },
+        )
+
+        assert response.status_code == 200
+        assert "success" in response.json()["message"].lower()
+
+        # Verify can login with new password
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "pwchangeuser", "password": "newpassword456"},
+        )
+        assert login_response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_change_password_wrong_current(self, async_client: AsyncClient, user_token: str):
+        """Verify changing password fails with wrong current password."""
+        response = await async_client.post(
+            "/api/v1/users/me/change-password",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={
+                "current_password": "wrongpassword",
+                "new_password": "newpassword456",
+            },
+        )
+
+        assert response.status_code == 400
+        assert "incorrect" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_change_password_requires_auth(self, async_client: AsyncClient):
+        """Verify changing password requires authentication."""
+        response = await async_client.post(
+            "/api/v1/users/me/change-password",
+            json={
+                "current_password": "oldpassword",
+                "new_password": "newpassword",
+            },
+        )
+
+        assert response.status_code == 401

+ 236 - 0
backend/tests/integration/test_library_api.py

@@ -1,5 +1,10 @@
 """Integration tests for Library API endpoints."""
 """Integration tests for Library API endpoints."""
 
 
+import io
+import tempfile
+import zipfile
+from pathlib import Path
+
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
@@ -479,3 +484,234 @@ class TestLibraryZipExtractAPI:
         assert folder_response.status_code == 200
         assert folder_response.status_code == 200
         folder = folder_response.json()
         folder = folder_response.json()
         assert folder["name"] == "MyProject"
         assert folder["name"] == "MyProject"
+
+
+class TestLibraryStlThumbnailAPI:
+    """Integration tests for STL thumbnail generation endpoints."""
+
+    @pytest.fixture
+    async def file_factory(self, db_session):
+        """Factory to create test files."""
+        _counter = [0]
+
+        async def _create_file(**kwargs):
+            from backend.app.models.library import LibraryFile
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"test_model_{counter}.stl",
+                "file_path": f"/test/path/test_model_{counter}.stl",
+                "file_size": 1024,
+                "file_type": "stl",
+            }
+            defaults.update(kwargs)
+
+            lib_file = LibraryFile(**defaults)
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+            return lib_file
+
+        return _create_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session):
+        """Verify batch thumbnail generation with no files."""
+        data = {"all_missing": True}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["processed"] == 0
+        assert result["succeeded"] == 0
+        assert result["failed"] == 0
+        assert result["results"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session):
+        """Verify batch thumbnail generation with no criteria returns empty."""
+        data = {}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["processed"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_file_not_on_disk(
+        self, async_client: AsyncClient, file_factory, db_session
+    ):
+        """Verify batch thumbnail generation handles missing files gracefully."""
+        # Create a file in DB but not on disk
+        stl_file = await file_factory(
+            filename="missing.stl",
+            file_path="/nonexistent/path/missing.stl",
+            thumbnail_path=None,
+        )
+
+        data = {"file_ids": [stl_file.id]}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["processed"] == 1
+        assert result["succeeded"] == 0
+        assert result["failed"] == 1
+        assert result["results"][0]["success"] is False
+        assert "not found" in result["results"][0]["error"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session):
+        """Verify batch thumbnail generation with a real STL file."""
+        from backend.app.models.library import LibraryFile
+
+        # Create a simple ASCII STL cube
+        stl_content = """solid cube
+facet normal 0 0 -1
+  outer loop
+    vertex 0 0 0
+    vertex 1 0 0
+    vertex 1 1 0
+  endloop
+endfacet
+facet normal 0 0 1
+  outer loop
+    vertex 0 0 1
+    vertex 1 1 1
+    vertex 1 0 1
+  endloop
+endfacet
+endsolid cube"""
+
+        with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as f:
+            f.write(stl_content)
+            stl_path = f.name
+
+        try:
+            # Create file in DB pointing to real STL
+            lib_file = LibraryFile(
+                filename="test_cube.stl",
+                file_path=stl_path,
+                file_size=len(stl_content),
+                file_type="stl",
+                thumbnail_path=None,
+            )
+            db_session.add(lib_file)
+            await db_session.commit()
+            await db_session.refresh(lib_file)
+
+            data = {"file_ids": [lib_file.id]}
+            response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+            assert response.status_code == 200
+            result = response.json()
+            assert result["processed"] == 1
+            # Result depends on whether trimesh/matplotlib are installed
+            # Either succeeds or fails gracefully
+            assert result["succeeded"] + result["failed"] == 1
+        finally:
+            import os
+
+            if os.path.exists(stl_path):
+                os.unlink(stl_path)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
+        """Verify file upload accepts generate_stl_thumbnails parameter."""
+        # Create a simple STL file
+        stl_content = b"solid test\nendsolid test"
+
+        files = {"file": ("test.stl", stl_content, "application/octet-stream")}
+        params = {"generate_stl_thumbnails": "false"}
+        response = await async_client.post("/api/v1/library/files", files=files, params=params)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["filename"] == "test.stl"
+        assert result["file_type"] == "stl"
+        # No thumbnail should be generated when disabled
+        assert result["thumbnail_path"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction accepts generate_stl_thumbnails parameter."""
+        # Create a ZIP file containing an STL
+        stl_content = b"solid test\nendsolid test"
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("model.stl", stl_content)
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        params = {"generate_stl_thumbnails": "false"}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["extracted"] == 1
+        assert result["files"][0]["filename"] == "model.stl"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify batch thumbnail generation can filter by folder."""
+        from backend.app.models.library import LibraryFolder
+
+        # Create a folder
+        folder = LibraryFolder(name="STL Folder")
+        db_session.add(folder)
+        await db_session.commit()
+        await db_session.refresh(folder)
+
+        # Create STL file in folder (no thumbnail)
+        stl_in_folder = await file_factory(
+            filename="in_folder.stl",
+            folder_id=folder.id,
+            thumbnail_path=None,
+        )
+
+        # Create STL file at root (no thumbnail)
+        _stl_at_root = await file_factory(
+            filename="at_root.stl",
+            folder_id=None,
+            thumbnail_path=None,
+        )
+
+        # Request thumbnails only for files in folder
+        data = {"folder_id": folder.id, "all_missing": True}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        # Should only process the file in the folder
+        assert result["processed"] == 1
+        assert result["results"][0]["file_id"] == stl_in_folder.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session):
+        """Verify batch thumbnail generation finds all STL files missing thumbnails."""
+        # Create files with and without thumbnails
+        _stl_with_thumb = await file_factory(
+            filename="with_thumb.stl",
+            thumbnail_path="/some/path/thumb.png",
+        )
+        stl_without_thumb1 = await file_factory(
+            filename="without_thumb1.stl",
+            thumbnail_path=None,
+        )
+        stl_without_thumb2 = await file_factory(
+            filename="without_thumb2.stl",
+            thumbnail_path=None,
+        )
+
+        data = {"all_missing": True}
+        response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        # Should only process files without thumbnails
+        assert result["processed"] == 2
+        file_ids = {r["file_id"] for r in result["results"]}
+        assert stl_without_thumb1.id in file_ids
+        assert stl_without_thumb2.id in file_ids

+ 252 - 0
backend/tests/integration/test_smart_plugs_api.py

@@ -573,3 +573,255 @@ class TestSmartPlugsAPI:
 
 
         assert response.status_code == 400
         assert response.status_code == 400
         assert "not configured" in response.json()["detail"].lower()
         assert "not configured" in response.json()["detail"].lower()
+
+    # ========================================================================
+    # MQTT Integration tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with topic and JSON paths."""
+        data = {
+            "name": "MQTT Energy Monitor",
+            "plug_type": "mqtt",
+            "mqtt_topic": "zigbee2mqtt/shelly-working-room",
+            "mqtt_power_path": "power_l1",
+            "mqtt_energy_path": "energy_l1",
+            "mqtt_state_path": "state_l1",
+            "mqtt_multiplier": 1.0,
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "MQTT Energy Monitor"
+        assert result["plug_type"] == "mqtt"
+        assert result["mqtt_topic"] == "zigbee2mqtt/shelly-working-room"
+        assert result["mqtt_power_path"] == "power_l1"
+        assert result["mqtt_energy_path"] == "energy_l1"
+        assert result["mqtt_state_path"] == "state_l1"
+        assert result["mqtt_multiplier"] == 1.0
+        assert result["ip_address"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
+        """Verify creating MQTT plug without topic fails."""
+        data = {
+            "name": "MQTT Plug",
+            "plug_type": "mqtt",
+            # Missing mqtt_topic
+            "mqtt_power_path": "power",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 422  # Validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_missing_topic(self, async_client: AsyncClient):
+        """Verify creating MQTT plug without any topic fails."""
+        data = {
+            "name": "MQTT Plug",
+            "plug_type": "mqtt",
+            # No topic configured at all
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 422  # Validation error
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_with_multiplier(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can use multiplier for unit conversion."""
+        data = {
+            "name": "MQTT mW to W",
+            "plug_type": "mqtt",
+            "mqtt_topic": "sensors/power",
+            "mqtt_power_path": "power_mw",
+            "mqtt_multiplier": 0.001,  # Convert mW to W
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_multiplier"] == 0.001
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_mqtt_plug_returns_error(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify MQTT plugs cannot be controlled (monitor-only)."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_topic="test/topic",
+            mqtt_power_path="power",
+        )
+
+        response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"})
+
+        assert response.status_code == 400
+        assert "monitor-only" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mqtt_plug_topic(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify MQTT plug topic can be updated."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_topic="old/topic",
+            mqtt_power_path="power",
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "mqtt_topic": "new/topic",
+                "mqtt_power_path": "new_power",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_topic"] == "new/topic"
+        assert result["mqtt_power_path"] == "new_power"
+
+    # ========================================================================
+    # Enhanced MQTT Integration tests (separate topics per data type)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_with_separate_topics(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with separate topics for power, energy, and state."""
+        data = {
+            "name": "MQTT Separate Topics",
+            "plug_type": "mqtt",
+            "mqtt_power_topic": "zigbee/power",
+            "mqtt_power_path": "power_l1",
+            "mqtt_power_multiplier": 0.001,
+            "mqtt_energy_topic": "zigbee/energy",
+            "mqtt_energy_path": "energy_total",
+            "mqtt_energy_multiplier": 1.0,
+            "mqtt_state_topic": "zigbee/state",
+            "mqtt_state_path": "state",
+            "mqtt_state_on_value": "ON",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "MQTT Separate Topics"
+        assert result["plug_type"] == "mqtt"
+        # Power fields
+        assert result["mqtt_power_topic"] == "zigbee/power"
+        assert result["mqtt_power_path"] == "power_l1"
+        assert result["mqtt_power_multiplier"] == 0.001
+        # Energy fields
+        assert result["mqtt_energy_topic"] == "zigbee/energy"
+        assert result["mqtt_energy_path"] == "energy_total"
+        assert result["mqtt_energy_multiplier"] == 1.0
+        # State fields
+        assert result["mqtt_state_topic"] == "zigbee/state"
+        assert result["mqtt_state_path"] == "state"
+        assert result["mqtt_state_on_value"] == "ON"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_energy_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with only energy monitoring."""
+        data = {
+            "name": "Energy Only Monitor",
+            "plug_type": "mqtt",
+            "mqtt_energy_topic": "sensors/energy",
+            "mqtt_energy_path": "kwh",
+            "mqtt_energy_multiplier": 0.001,  # Wh to kWh
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_energy_topic"] == "sensors/energy"
+        assert result["mqtt_energy_path"] == "kwh"
+        assert result["mqtt_energy_multiplier"] == 0.001
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_state_only(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify MQTT plug can be created with only state monitoring."""
+        data = {
+            "name": "State Only Monitor",
+            "plug_type": "mqtt",
+            "mqtt_state_topic": "switches/outlet",
+            "mqtt_state_path": "state",
+            "mqtt_state_on_value": "true",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_state_topic"] == "switches/outlet"
+        assert result["mqtt_state_path"] == "state"
+        assert result["mqtt_state_on_value"] == "true"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_mqtt_plug_topic_only_succeeds(self, async_client: AsyncClient, mock_mqtt_smart_plug_service):
+        """Verify creating MQTT plug with topic only (no path) succeeds for raw values."""
+        data = {
+            "name": "Raw MQTT Plug",
+            "plug_type": "mqtt",
+            # Topic only, no path - valid for raw numeric MQTT values
+            "mqtt_power_topic": "zigbee/power",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200  # Should succeed
+        result = response.json()
+        assert result["mqtt_power_topic"] == "zigbee/power"
+        assert result["mqtt_power_path"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_mqtt_plug_separate_multipliers(
+        self, async_client: AsyncClient, smart_plug_factory, db_session, mock_mqtt_smart_plug_service
+    ):
+        """Verify MQTT plug multipliers can be updated separately."""
+        plug = await smart_plug_factory(
+            plug_type="mqtt",
+            mqtt_power_topic="test/power",
+            mqtt_power_path="power",
+            mqtt_power_multiplier=1.0,
+            mqtt_energy_topic="test/energy",
+            mqtt_energy_path="energy",
+            mqtt_energy_multiplier=1.0,
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "mqtt_power_multiplier": 0.001,  # Change power multiplier only
+                "mqtt_energy_multiplier": 0.001,  # Change energy multiplier only
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["mqtt_power_multiplier"] == 0.001
+        assert result["mqtt_energy_multiplier"] == 0.001

+ 204 - 0
backend/tests/unit/services/test_stl_thumbnail.py

@@ -0,0 +1,204 @@
+"""Unit tests for the STL thumbnail service."""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+
+def _check_trimesh_available():
+    """Check if trimesh is available for import."""
+    try:
+        import trimesh
+
+        return True
+    except ImportError:
+        return False
+
+
+class TestStlThumbnailService:
+    """Tests for STL thumbnail generation service."""
+
+    def test_generate_stl_thumbnail_imports_available(self):
+        """Test that required imports are available."""
+        try:
+            import matplotlib
+            import trimesh
+
+            assert trimesh is not None
+            assert matplotlib is not None
+        except ImportError as e:
+            pytest.skip(f"Required dependencies not installed: {e}")
+
+    def test_generate_stl_thumbnail_returns_none_on_missing_deps(self):
+        """Test graceful degradation when dependencies are missing."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "test.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            # Create a dummy STL file (will fail to parse)
+            stl_path.write_text("invalid stl content")
+
+            # Should return None on failure, not raise
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+            assert result is None
+
+    @pytest.mark.skipif(
+        not _check_trimesh_available(),
+        reason="trimesh not installed",
+    )
+    def test_generate_stl_thumbnail_with_simple_cube(self):
+        """Test thumbnail generation with a simple cube STL."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "cube.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            # Create a simple ASCII STL cube
+            stl_content = """solid cube
+facet normal 0 0 -1
+  outer loop
+    vertex 0 0 0
+    vertex 1 0 0
+    vertex 1 1 0
+  endloop
+endfacet
+facet normal 0 0 -1
+  outer loop
+    vertex 0 0 0
+    vertex 1 1 0
+    vertex 0 1 0
+  endloop
+endfacet
+facet normal 0 0 1
+  outer loop
+    vertex 0 0 1
+    vertex 1 1 1
+    vertex 1 0 1
+  endloop
+endfacet
+facet normal 0 0 1
+  outer loop
+    vertex 0 0 1
+    vertex 0 1 1
+    vertex 1 1 1
+  endloop
+endfacet
+facet normal 0 -1 0
+  outer loop
+    vertex 0 0 0
+    vertex 1 0 1
+    vertex 1 0 0
+  endloop
+endfacet
+facet normal 0 -1 0
+  outer loop
+    vertex 0 0 0
+    vertex 0 0 1
+    vertex 1 0 1
+  endloop
+endfacet
+facet normal 1 0 0
+  outer loop
+    vertex 1 0 0
+    vertex 1 0 1
+    vertex 1 1 1
+  endloop
+endfacet
+facet normal 1 0 0
+  outer loop
+    vertex 1 0 0
+    vertex 1 1 1
+    vertex 1 1 0
+  endloop
+endfacet
+facet normal 0 1 0
+  outer loop
+    vertex 0 1 0
+    vertex 1 1 0
+    vertex 1 1 1
+  endloop
+endfacet
+facet normal 0 1 0
+  outer loop
+    vertex 0 1 0
+    vertex 1 1 1
+    vertex 0 1 1
+  endloop
+endfacet
+facet normal -1 0 0
+  outer loop
+    vertex 0 0 0
+    vertex 0 1 0
+    vertex 0 1 1
+  endloop
+endfacet
+facet normal -1 0 0
+  outer loop
+    vertex 0 0 0
+    vertex 0 1 1
+    vertex 0 0 1
+  endloop
+endfacet
+endsolid cube"""
+            stl_path.write_text(stl_content)
+
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+
+            # Should return a path to the generated thumbnail
+            if result:
+                assert Path(result).exists()
+                assert Path(result).suffix == ".png"
+            # If result is None, dependencies might not be fully functional
+            # which is acceptable
+
+    def test_generate_stl_thumbnail_nonexistent_file(self):
+        """Test thumbnail generation with nonexistent file."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "nonexistent.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+            assert result is None
+
+    def test_generate_stl_thumbnail_empty_file(self):
+        """Test thumbnail generation with empty file."""
+        from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            stl_path = Path(tmpdir) / "empty.stl"
+            thumbnails_dir = Path(tmpdir)
+
+            # Create empty file
+            stl_path.write_bytes(b"")
+
+            result = generate_stl_thumbnail(stl_path, thumbnails_dir)
+            assert result is None
+
+
+class TestStlThumbnailConstants:
+    """Tests for STL thumbnail service constants."""
+
+    def test_bambu_green_color(self):
+        """Test that Bambu green color is defined."""
+        from backend.app.services.stl_thumbnail import BAMBU_GREEN
+
+        assert BAMBU_GREEN == "#00AE42"
+
+    def test_background_color(self):
+        """Test that background color is defined."""
+        from backend.app.services.stl_thumbnail import BACKGROUND_COLOR
+
+        assert BACKGROUND_COLOR == "#1a1a1a"
+
+    def test_max_vertices_threshold(self):
+        """Test that max vertices threshold is defined."""
+        from backend.app.services.stl_thumbnail import MAX_VERTICES
+
+        assert MAX_VERTICES == 100000

+ 0 - 4
demo-video/.gitignore

@@ -1,4 +0,0 @@
-node_modules/
-output/
-*.webm
-*.mp4

+ 0 - 50
demo-video/README.md

@@ -1,50 +0,0 @@
-# Bambuddy Demo Video Recorder
-
-Automated demo video recording using Playwright.
-
-## Setup
-
-```bash
-cd demo-video
-npm install
-npm run install-browsers
-```
-
-## Recording
-
-### Record with visible browser (recommended for debugging)
-```bash
-npm run record
-```
-
-### Record headless (faster, no window)
-```bash
-npm run record:headless
-```
-
-### Custom URL
-```bash
-DEMO_URL=https://your-bambuddy.example.com npm run record
-```
-
-## Output
-
-Videos are saved to `output/` as `.webm` files.
-
-### Convert to MP4
-```bash
-ffmpeg -i output/video.webm -c:v libx264 -crf 23 demo.mp4
-```
-
-### Convert with better quality
-```bash
-ffmpeg -i output/video.webm -c:v libx264 -crf 18 -preset slow demo.mp4
-```
-
-## Customization
-
-Edit `record-demo.ts` to:
-- Adjust timing (TIMING constants)
-- Add/remove page demonstrations
-- Customize interactions per page
-- Change viewport resolution (CONFIG)

+ 0 - 537
demo-video/package-lock.json

@@ -1,537 +0,0 @@
-{
-  "name": "bambuddy-demo-video",
-  "version": "1.0.0",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "name": "bambuddy-demo-video",
-      "version": "1.0.0",
-      "dependencies": {
-        "playwright": "^1.40.0",
-        "tsx": "^4.7.0"
-      }
-    },
-    "node_modules/@esbuild/aix-ppc64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
-      "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
-      "cpu": [
-        "ppc64"
-      ],
-      "optional": true,
-      "os": [
-        "aix"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
-      "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
-      "cpu": [
-        "arm"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
-      "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/android-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
-      "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "android"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
-      "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/darwin-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
-      "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
-      "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/freebsd-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
-      "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "freebsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
-      "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
-      "cpu": [
-        "arm"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
-      "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ia32": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
-      "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
-      "cpu": [
-        "ia32"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-loong64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
-      "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
-      "cpu": [
-        "loong64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-mips64el": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
-      "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
-      "cpu": [
-        "mips64el"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-ppc64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
-      "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
-      "cpu": [
-        "ppc64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-riscv64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
-      "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-s390x": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
-      "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
-      "cpu": [
-        "s390x"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/linux-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
-      "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
-      "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/netbsd-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
-      "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "netbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
-      "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openbsd-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
-      "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "openbsd"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/openharmony-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
-      "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "openharmony"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/sunos-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
-      "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "sunos"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-arm64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
-      "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
-      "cpu": [
-        "arm64"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-ia32": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
-      "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
-      "cpu": [
-        "ia32"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@esbuild/win32-x64": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
-      "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
-      "cpu": [
-        "x64"
-      ],
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/esbuild": {
-      "version": "0.27.2",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
-      "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
-      "hasInstallScript": true,
-      "bin": {
-        "esbuild": "bin/esbuild"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "@esbuild/aix-ppc64": "0.27.2",
-        "@esbuild/android-arm": "0.27.2",
-        "@esbuild/android-arm64": "0.27.2",
-        "@esbuild/android-x64": "0.27.2",
-        "@esbuild/darwin-arm64": "0.27.2",
-        "@esbuild/darwin-x64": "0.27.2",
-        "@esbuild/freebsd-arm64": "0.27.2",
-        "@esbuild/freebsd-x64": "0.27.2",
-        "@esbuild/linux-arm": "0.27.2",
-        "@esbuild/linux-arm64": "0.27.2",
-        "@esbuild/linux-ia32": "0.27.2",
-        "@esbuild/linux-loong64": "0.27.2",
-        "@esbuild/linux-mips64el": "0.27.2",
-        "@esbuild/linux-ppc64": "0.27.2",
-        "@esbuild/linux-riscv64": "0.27.2",
-        "@esbuild/linux-s390x": "0.27.2",
-        "@esbuild/linux-x64": "0.27.2",
-        "@esbuild/netbsd-arm64": "0.27.2",
-        "@esbuild/netbsd-x64": "0.27.2",
-        "@esbuild/openbsd-arm64": "0.27.2",
-        "@esbuild/openbsd-x64": "0.27.2",
-        "@esbuild/openharmony-arm64": "0.27.2",
-        "@esbuild/sunos-x64": "0.27.2",
-        "@esbuild/win32-arm64": "0.27.2",
-        "@esbuild/win32-ia32": "0.27.2",
-        "@esbuild/win32-x64": "0.27.2"
-      }
-    },
-    "node_modules/fsevents": {
-      "version": "2.3.2",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
-      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
-      "hasInstallScript": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    },
-    "node_modules/get-tsconfig": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
-      "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
-      "dependencies": {
-        "resolve-pkg-maps": "^1.0.0"
-      },
-      "funding": {
-        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
-      }
-    },
-    "node_modules/playwright": {
-      "version": "1.57.0",
-      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
-      "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
-      "dependencies": {
-        "playwright-core": "1.57.0"
-      },
-      "bin": {
-        "playwright": "cli.js"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "optionalDependencies": {
-        "fsevents": "2.3.2"
-      }
-    },
-    "node_modules/playwright-core": {
-      "version": "1.57.0",
-      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
-      "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
-      "bin": {
-        "playwright-core": "cli.js"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/resolve-pkg-maps": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
-      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
-      "funding": {
-        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
-      }
-    },
-    "node_modules/tsx": {
-      "version": "4.21.0",
-      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
-      "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
-      "dependencies": {
-        "esbuild": "~0.27.0",
-        "get-tsconfig": "^4.7.5"
-      },
-      "bin": {
-        "tsx": "dist/cli.mjs"
-      },
-      "engines": {
-        "node": ">=18.0.0"
-      },
-      "optionalDependencies": {
-        "fsevents": "~2.3.3"
-      }
-    },
-    "node_modules/tsx/node_modules/fsevents": {
-      "version": "2.3.3",
-      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
-      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
-      "hasInstallScript": true,
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "engines": {
-        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
-      }
-    }
-  }
-}

+ 0 - 15
demo-video/package.json

@@ -1,15 +0,0 @@
-{
-  "name": "bambuddy-demo-video",
-  "version": "1.0.0",
-  "description": "Automated demo video recording for Bambuddy",
-  "type": "module",
-  "scripts": {
-    "record": "npx tsx record-demo.ts",
-    "record:headless": "HEADLESS=true npx tsx record-demo.ts",
-    "install-browsers": "npx playwright install chromium"
-  },
-  "dependencies": {
-    "playwright": "^1.40.0",
-    "tsx": "^4.7.0"
-  }
-}

+ 0 - 566
demo-video/record-demo.ts

@@ -1,566 +0,0 @@
-import { chromium, Page, Browser, BrowserContext } from 'playwright';
-import path from 'path';
-import { fileURLToPath } from 'url';
-
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
-// Configuration
-const CONFIG = {
-  baseUrl: process.env.DEMO_URL || 'http://localhost:8000',
-  headless: process.env.HEADLESS === 'true',
-  slowMo: 50, // Slow down actions for visibility
-  viewportWidth: 1920,
-  viewportHeight: 1080,
-  outputDir: path.join(__dirname, 'output'),
-};
-
-// Timing helpers (in ms)
-const TIMING = {
-  pageLoad: 1500,      // Wait after page navigation
-  shortPause: 500,     // Brief pause between actions
-  mediumPause: 1000,   // Standard pause for visibility
-  longPause: 2000,     // Longer pause for important features
-  modalOpen: 800,      // Wait for modal animations
-  scrollPause: 600,    // Pause after scrolling
-};
-
-async function wait(ms: number): Promise<void> {
-  return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-async function scrollDown(page: Page, pixels: number = 300): Promise<void> {
-  await page.mouse.wheel(0, pixels);
-  await wait(TIMING.scrollPause);
-}
-
-async function scrollToTop(page: Page): Promise<void> {
-  await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
-  await wait(TIMING.scrollPause);
-}
-
-async function hoverElement(page: Page, selector: string): Promise<void> {
-  const element = page.locator(selector).first();
-  if (await element.isVisible()) {
-    await element.hover();
-    await wait(TIMING.shortPause);
-  }
-}
-
-async function clickIfVisible(page: Page, selector: string): Promise<boolean> {
-  const element = page.locator(selector).first();
-  if (await element.isVisible()) {
-    await element.click();
-    return true;
-  }
-  return false;
-}
-
-async function closeModalIfOpen(page: Page): Promise<void> {
-  // Try to close any open modal by pressing Escape
-  await page.keyboard.press('Escape');
-  await wait(TIMING.shortPause);
-}
-
-async function blurSensitiveContent(page: Page): Promise<void> {
-  // Use JavaScript to find and blur email addresses
-  await page.evaluate(() => {
-    // Find all spans and check for email patterns
-    document.querySelectorAll('span').forEach(el => {
-      const text = el.textContent || '';
-      // Check if this specific element (not children) contains an email
-      if (el.childNodes.length === 1 && el.childNodes[0].nodeType === Node.TEXT_NODE) {
-        if (text.includes('@') && text.includes('.')) {
-          (el as HTMLElement).style.filter = 'blur(6px)';
-          (el as HTMLElement).style.userSelect = 'none';
-        }
-      }
-    });
-
-    // Also find "Connected as" text and blur the next sibling span
-    document.querySelectorAll('span').forEach(el => {
-      if (el.textContent?.includes('Connected as')) {
-        const emailSpan = el.querySelector('span');
-        if (emailSpan) {
-          (emailSpan as HTMLElement).style.filter = 'blur(6px)';
-          (emailSpan as HTMLElement).style.userSelect = 'none';
-        }
-      }
-    });
-  });
-}
-
-// ============================================================================
-// Page Scenarios
-// ============================================================================
-
-async function demoPrintersPage(page: Page): Promise<void> {
-  console.log('📷 Demonstrating Printers page...');
-  await page.goto(CONFIG.baseUrl);
-  await wait(TIMING.pageLoad);
-
-  // Hover over printer cards to show interactions
-  const printerCards = page.locator('.group').filter({ has: page.locator('img') });
-  const cardCount = await printerCards.count();
-  console.log(`   Found ${cardCount} printer cards`);
-
-  for (let i = 0; i < Math.min(cardCount, 2); i++) {
-    const card = printerCards.nth(i);
-    if (await card.isVisible()) {
-      await card.hover();
-      await wait(TIMING.mediumPause);
-
-      // Try clicking on card to expand/show details
-      await card.click();
-      await wait(TIMING.mediumPause);
-    }
-  }
-
-  // Look for AMS section and hover over slots
-  const amsSlots = page.locator('[class*="ams"], [class*="AMS"]').first();
-  if (await amsSlots.isVisible()) {
-    await amsSlots.hover();
-    await wait(TIMING.mediumPause);
-  }
-
-  // Try to open camera modal
-  const cameraIcon = page.locator('svg[class*="lucide-video"], button:has(svg)').first();
-  if (await cameraIcon.isVisible()) {
-    await cameraIcon.click();
-    await wait(TIMING.longPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Try to open MQTT debug modal
-  const debugButton = page.locator('button:has-text("Debug"), button:has-text("MQTT")').first();
-  if (await debugButton.isVisible()) {
-    await debugButton.click();
-    await wait(TIMING.longPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Scroll to show more printers
-  await scrollDown(page, 400);
-  await wait(TIMING.mediumPause);
-  await scrollToTop(page);
-}
-
-async function demoArchivesPage(page: Page): Promise<void> {
-  console.log('📁 Demonstrating Archives page...');
-  await page.goto(`${CONFIG.baseUrl}/archives`);
-  await wait(TIMING.pageLoad);
-
-  // Show view mode toggle (grid/list/calendar)
-  const viewToggle = page.locator('button:has(svg[class*="grid"]), button:has(svg[class*="list"])');
-  if (await viewToggle.first().isVisible()) {
-    await viewToggle.first().click();
-    await wait(TIMING.mediumPause);
-    await viewToggle.first().click(); // Toggle back
-    await wait(TIMING.shortPause);
-  }
-
-  // Use search
-  const searchInput = page.locator('input[placeholder*="Search"], input[type="search"]').first();
-  if (await searchInput.isVisible()) {
-    await searchInput.click();
-    await searchInput.fill('engine');
-    await wait(TIMING.longPause);
-    await searchInput.clear();
-    await wait(TIMING.shortPause);
-  }
-
-  // Show filter dropdowns
-  const filterButtons = page.locator('button:has-text("Printer"), button:has-text("Material"), button:has-text("Filter")');
-  if (await filterButtons.first().isVisible()) {
-    await filterButtons.first().click();
-    await wait(TIMING.mediumPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Right-click to show context menu
-  const archiveCard = page.locator('.group').filter({ has: page.locator('img') }).first();
-  if (await archiveCard.isVisible()) {
-    await archiveCard.click({ button: 'right' });
-    await wait(TIMING.longPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Click on archive to open edit modal
-  if (await archiveCard.isVisible()) {
-    await archiveCard.dblclick();
-    await wait(TIMING.longPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Scroll to show more archives
-  await scrollDown(page, 500);
-  await wait(TIMING.mediumPause);
-  await scrollToTop(page);
-}
-
-async function demoQueuePage(page: Page): Promise<void> {
-  console.log('📋 Demonstrating Queue page...');
-  await page.goto(`${CONFIG.baseUrl}/queue`);
-  await wait(TIMING.pageLoad);
-
-  // Show filter dropdowns
-  const printerFilter = page.locator('button:has-text("Printer"), select').first();
-  if (await printerFilter.isVisible()) {
-    await printerFilter.click();
-    await wait(TIMING.mediumPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Show sort controls
-  const sortButton = page.locator('button:has-text("Sort"), button:has(svg[class*="arrow"])').first();
-  if (await sortButton.isVisible()) {
-    await sortButton.click();
-    await wait(TIMING.mediumPause);
-  }
-
-  // Hover over queue items to show drag handles
-  const queueItems = page.locator('[draggable="true"], .group').first();
-  if (await queueItems.isVisible()) {
-    await queueItems.hover();
-    await wait(TIMING.mediumPause);
-  }
-
-  // Scroll through queue
-  await scrollDown(page, 300);
-  await wait(TIMING.mediumPause);
-  await scrollToTop(page);
-}
-
-async function demoStatsPage(page: Page): Promise<void> {
-  console.log('📊 Demonstrating Stats page...');
-  await page.goto(`${CONFIG.baseUrl}/stats`);
-  await wait(TIMING.pageLoad);
-
-  // Let charts animate
-  await wait(TIMING.longPause);
-
-  // Show export dropdown
-  const exportButton = page.locator('button:has-text("Export"), button:has(svg[class*="download"])').first();
-  if (await exportButton.isVisible()) {
-    await exportButton.click();
-    await wait(TIMING.mediumPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Scroll through stats widgets
-  await scrollDown(page, 400);
-  await wait(TIMING.mediumPause);
-  await scrollDown(page, 400);
-  await wait(TIMING.mediumPause);
-  await scrollDown(page, 400);
-  await wait(TIMING.mediumPause);
-  await scrollToTop(page);
-}
-
-async function demoProfilesPage(page: Page): Promise<void> {
-  console.log('⚙️ Demonstrating Profiles page...');
-
-  // Start blur loop BEFORE navigating
-  let blurring = true;
-  const blurLoop = async () => {
-    while (blurring) {
-      try {
-        await page.evaluate(() => {
-          document.querySelectorAll('span').forEach(el => {
-            if (el.textContent?.includes('Connected as')) {
-              const emailSpan = el.querySelector('span');
-              if (emailSpan) {
-                (emailSpan as HTMLElement).style.filter = 'blur(6px)';
-              }
-            }
-          });
-        });
-      } catch { /* page might be navigating */ }
-      await new Promise(r => setTimeout(r, 30));
-    }
-  };
-
-  // Start blur loop in background
-  const blurPromise = blurLoop();
-
-  await page.goto(`${CONFIG.baseUrl}/profiles`);
-  await wait(TIMING.pageLoad);
-
-  // Show Cloud Profiles section
-  await wait(TIMING.mediumPause);
-
-  // Click on K-Profiles tab if available
-  try {
-    const kProfilesTab = page.locator('button:has-text("K-Profile"), button:has-text("K Profile")').first();
-    if (await kProfilesTab.isVisible({ timeout: 1000 })) {
-      await kProfilesTab.click({ timeout: 2000 });
-      await wait(TIMING.mediumPause);
-      await scrollDown(page, 300);
-      await wait(TIMING.shortPause);
-      await scrollToTop(page);
-    }
-  } catch { /* skip */ }
-
-  // Click back to Cloud Profiles
-  try {
-    const cloudTab = page.locator('button:has-text("Cloud")').first();
-    if (await cloudTab.isVisible({ timeout: 1000 })) {
-      await cloudTab.click({ timeout: 2000 });
-      await wait(TIMING.mediumPause);
-    }
-  } catch { /* skip */ }
-
-  // Show preset filter types (if visible) - use force to bypass overlays
-  const presetFilters = page.locator('button:has-text("Filament"), button:has-text("Process"), button:has-text("Machine")');
-  for (let i = 0; i < 3; i++) {
-    try {
-      const filter = presetFilters.nth(i);
-      if (await filter.isVisible({ timeout: 1000 })) {
-        await filter.click({ force: true, timeout: 2000 });
-        await wait(TIMING.shortPause);
-      }
-    } catch { /* skip if not visible or blocked */ }
-  }
-
-  await scrollDown(page, 300);
-  await wait(TIMING.shortPause);
-  await scrollToTop(page);
-
-  // Stop blur loop
-  blurring = false;
-  await blurPromise;
-}
-
-async function demoMaintenancePage(page: Page): Promise<void> {
-  console.log('🔧 Demonstrating Maintenance page...');
-  await page.goto(`${CONFIG.baseUrl}/maintenance`);
-  await wait(TIMING.pageLoad);
-
-  // Show status tab (default)
-  await wait(TIMING.mediumPause);
-
-  // Expand a printer section if available
-  const expandButton = page.locator('button:has(svg[class*="chevron"])').first();
-  if (await expandButton.isVisible()) {
-    await expandButton.click();
-    await wait(TIMING.mediumPause);
-  }
-
-  // Scroll through status
-  await scrollDown(page, 300);
-  await wait(TIMING.shortPause);
-  await scrollToTop(page);
-
-  // Click Settings tab
-  const settingsTab = page.locator('button:has-text("Settings"), [role="tab"]:has-text("Settings")').first();
-  if (await settingsTab.isVisible()) {
-    await settingsTab.click();
-    await wait(TIMING.mediumPause);
-
-    // Scroll through settings
-    await scrollDown(page, 300);
-    await wait(TIMING.shortPause);
-    await scrollToTop(page);
-  }
-
-  // Go back to Status tab
-  const statusTab = page.locator('button:has-text("Status"), [role="tab"]:has-text("Status")').first();
-  if (await statusTab.isVisible()) {
-    await statusTab.click();
-    await wait(TIMING.shortPause);
-  }
-}
-
-async function demoProjectsPage(page: Page): Promise<void> {
-  console.log('📂 Demonstrating Projects page...');
-  await page.goto(`${CONFIG.baseUrl}/projects`);
-  await wait(TIMING.pageLoad);
-
-  // Click through status filter buttons
-  const statusFilters = ['Active', 'Completed', 'Archived', 'All'];
-  for (const status of statusFilters) {
-    const filterBtn = page.locator(`button:has-text("${status}")`).first();
-    if (await filterBtn.isVisible()) {
-      await filterBtn.click();
-      await wait(TIMING.shortPause);
-    }
-  }
-
-  // Click on a project to go to detail page
-  const projectCard = page.locator('.group, [class*="project"]').filter({ has: page.locator('h3, h2') }).first();
-  if (await projectCard.isVisible()) {
-    await projectCard.click();
-    await wait(TIMING.pageLoad);
-
-    // Scroll through project detail
-    await scrollDown(page, 300);
-    await wait(TIMING.mediumPause);
-
-    // Look for tabs in project detail (BOM, Attachments, Prints)
-    const detailTabs = ['BOM', 'Attachments', 'Prints', 'Notes'];
-    for (const tabName of detailTabs) {
-      const tab = page.locator(`button:has-text("${tabName}"), [role="tab"]:has-text("${tabName}")`).first();
-      if (await tab.isVisible()) {
-        await tab.click();
-        await wait(TIMING.mediumPause);
-      }
-    }
-
-    await scrollToTop(page);
-  }
-}
-
-async function demoSettingsPage(page: Page): Promise<void> {
-  console.log('⚙️ Demonstrating Settings page...');
-  await page.goto(`${CONFIG.baseUrl}/settings`);
-  await wait(TIMING.pageLoad);
-
-  // Define the 6 tabs to click through
-  const tabs = ['General', 'Plugs', 'Notifications', 'Filament', 'API', 'Virtual'];
-
-  for (const tabName of tabs) {
-    const tab = page.locator(`button:has-text("${tabName}"), [role="tab"]:has-text("${tabName}")`).first();
-    if (await tab.isVisible()) {
-      await tab.click();
-      await wait(TIMING.mediumPause);
-
-      // Scroll through tab content
-      await scrollDown(page, 300);
-      await wait(TIMING.shortPause);
-      await scrollToTop(page);
-    }
-  }
-
-  // Go back to General tab and show a modal
-  const generalTab = page.locator('button:has-text("General")').first();
-  if (await generalTab.isVisible()) {
-    await generalTab.click();
-    await wait(TIMING.shortPause);
-  }
-
-  // Try to open backup modal
-  const backupButton = page.locator('button:has-text("Backup")').first();
-  if (await backupButton.isVisible()) {
-    await backupButton.click();
-    await wait(TIMING.longPause);
-    await page.keyboard.press('Escape');
-    await wait(TIMING.shortPause);
-  }
-
-  // Go to Plugs tab and show add modal
-  const plugsTab = page.locator('button:has-text("Plugs")').first();
-  if (await plugsTab.isVisible()) {
-    await plugsTab.click();
-    await wait(TIMING.shortPause);
-
-    const addPlugButton = page.locator('button:has-text("Add"), button:has(svg[class*="plus"])').first();
-    if (await addPlugButton.isVisible()) {
-      await addPlugButton.click();
-      await wait(TIMING.longPause);
-      await page.keyboard.press('Escape');
-      await wait(TIMING.shortPause);
-    }
-  }
-
-  // Go to Notifications tab and show add modal
-  const notifTab = page.locator('button:has-text("Notifications")').first();
-  if (await notifTab.isVisible()) {
-    await notifTab.click();
-    await wait(TIMING.shortPause);
-
-    const addNotifButton = page.locator('button:has-text("Add"), button:has(svg[class*="plus"])').first();
-    if (await addNotifButton.isVisible()) {
-      await addNotifButton.click();
-      await wait(TIMING.longPause);
-      await page.keyboard.press('Escape');
-      await wait(TIMING.shortPause);
-    }
-  }
-
-  await scrollToTop(page);
-}
-
-async function demoSystemPage(page: Page): Promise<void> {
-  console.log('💻 Demonstrating System page...');
-  await page.goto(`${CONFIG.baseUrl}/system`);
-  await wait(TIMING.pageLoad);
-
-  // Show system info
-  await wait(TIMING.mediumPause);
-  await scrollDown(page, 300);
-  await wait(TIMING.shortPause);
-  await scrollToTop(page);
-}
-
-// ============================================================================
-// Main Recording Function
-// ============================================================================
-
-async function recordDemo(): Promise<void> {
-  console.log('🎬 Starting Bambuddy demo recording...');
-  console.log(`   URL: ${CONFIG.baseUrl}`);
-  console.log(`   Resolution: ${CONFIG.viewportWidth}x${CONFIG.viewportHeight}`);
-  console.log(`   Headless: ${CONFIG.headless}`);
-  console.log('');
-
-  const browser: Browser = await chromium.launch({
-    headless: CONFIG.headless,
-    slowMo: CONFIG.slowMo,
-  });
-
-  const context: BrowserContext = await browser.newContext({
-    viewport: {
-      width: CONFIG.viewportWidth,
-      height: CONFIG.viewportHeight,
-    },
-    recordVideo: {
-      dir: CONFIG.outputDir,
-      size: {
-        width: CONFIG.viewportWidth,
-        height: CONFIG.viewportHeight,
-      },
-    },
-  });
-
-  const page: Page = await context.newPage();
-
-  try {
-    // Run through all page demos
-    await demoPrintersPage(page);
-    await demoArchivesPage(page);
-    await demoQueuePage(page);
-    await demoStatsPage(page);
-    await demoProfilesPage(page);
-    await demoMaintenancePage(page);
-    await demoProjectsPage(page);
-    await demoSettingsPage(page);
-    await demoSystemPage(page);
-
-    // Return to home page for closing shot
-    console.log('🏠 Returning to home page...');
-    await page.goto(CONFIG.baseUrl);
-    await wait(TIMING.longPause);
-
-    console.log('✅ Demo recording completed!');
-  } catch (error) {
-    console.error('❌ Error during recording:', error);
-    throw error;
-  } finally {
-    await page.close();
-    await context.close();
-    await browser.close();
-  }
-
-  console.log(`\n📹 Video saved to: ${CONFIG.outputDir}/`);
-  console.log('   (Playwright saves as .webm, convert with ffmpeg if needed)');
-  console.log('   Example: ffmpeg -i video.webm -c:v libx264 demo.mp4');
-}
-
-// Run the recording
-recordDemo().catch(console.error);

+ 48 - 18
deploy/bambuddy.service

@@ -1,31 +1,61 @@
+# BamBuddy Systemd Service Template
+#
+# INSTALLATION:
+# 1. Copy this file to /etc/systemd/system/bambuddy.service
+# 2. Replace placeholders:
+#    - INSTALL_PATH: Where BamBuddy is installed (e.g., /opt/bambuddy)
+#    - SERVICE_USER: User to run as (e.g., bambuddy)
+#    - DATA_DIR: Data directory (e.g., /opt/bambuddy/data)
+#    - LOG_DIR: Log directory (e.g., /opt/bambuddy/logs)
+# 3. Run: sudo systemctl daemon-reload
+# 4. Run: sudo systemctl enable bambuddy
+# 5. Run: sudo systemctl start bambuddy
+#
+# Or use the install script: ./install/install.sh
+#
+
 [Unit]
 [Unit]
-Description=BamBuddy Print Archive
+Description=BamBuddy - Bambu Lab Print Management
+Documentation=https://github.com/maziggy/bambuddy
 After=network.target
 After=network.target
 
 
 [Service]
 [Service]
 Type=simple
 Type=simple
-User=claude
-Group=claude
-WorkingDirectory=<dir>/bambuddy
-Environment="PATH=<dir/bambuddy/venv/bin"
+User=SERVICE_USER
+Group=SERVICE_USER
+WorkingDirectory=INSTALL_PATH
+
+# Environment file (optional - created by install script)
+EnvironmentFile=-INSTALL_PATH/.env
+
+# Use virtual environment
+Environment="PATH=INSTALL_PATH/venv/bin:/usr/local/bin:/usr/bin:/bin"
+
+# Server configuration
+ExecStart=INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port ${PORT:-8000}
+
+# Restart policy
+Restart=on-failure
+RestartSec=5
 
 
-# Force kill after 10 seconds if graceful shutdown fails
+# Graceful shutdown
 TimeoutStopSec=10
 TimeoutStopSec=10
 
 
-# Kill any zombie ffmpeg processes before starting/after stopping
-ExecStartPre=-/usr/bin/pkill -9 ffmpeg
-ExecStopPost=-/usr/bin/pkill -9 ffmpeg
+# Kill zombie ffmpeg processes (timelapse processing)
+ExecStartPre=-/usr/bin/pkill -9 -f "ffmpeg.*bambuddy"
+ExecStopPost=-/usr/bin/pkill -9 -f "ffmpeg.*bambuddy"
 
 
-# Ensure directories exist and have correct permissions before starting
-# The + prefix runs the command as root even though User=claude
-ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/logs
-ExecStartPre=+/bin/mkdir -p <dir>/bambuddy/archive
-ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/logs
-ExecStartPre=+/bin/chown -R <user>:<user> <dir>/bambuddy/archive
+# Logging
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=bambuddy
 
 
-ExecStart=<dir>/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000
-Restart=always
-RestartSec=10
+# Security hardening
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=DATA_DIR LOG_DIR INSTALL_PATH
 
 
 [Install]
 [Install]
 WantedBy=multi-user.target
 WantedBy=multi-user.target

+ 4 - 4
frontend/package-lock.json

@@ -46,7 +46,7 @@
         "@vitejs/plugin-react": "^5.1.1",
         "@vitejs/plugin-react": "^5.1.1",
         "@vitest/coverage-v8": "^3.2.4",
         "@vitest/coverage-v8": "^3.2.4",
         "autoprefixer": "^10.4.22",
         "autoprefixer": "^10.4.22",
-        "baseline-browser-mapping": "^2.9.18",
+        "baseline-browser-mapping": "^2.9.19",
         "eslint": "^9.39.1",
         "eslint": "^9.39.1",
         "eslint-plugin-react-hooks": "^7.0.1",
         "eslint-plugin-react-hooks": "^7.0.1",
         "eslint-plugin-react-refresh": "^0.4.24",
         "eslint-plugin-react-refresh": "^0.4.24",
@@ -3638,9 +3638,9 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/baseline-browser-mapping": {
     "node_modules/baseline-browser-mapping": {
-      "version": "2.9.18",
-      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz",
-      "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==",
+      "version": "2.9.19",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
+      "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
       "dev": true,
       "dev": true,
       "bin": {
       "bin": {
         "baseline-browser-mapping": "dist/cli.js"
         "baseline-browser-mapping": "dist/cli.js"

+ 1 - 1
frontend/package.json

@@ -52,7 +52,7 @@
     "@vitejs/plugin-react": "^5.1.1",
     "@vitejs/plugin-react": "^5.1.1",
     "@vitest/coverage-v8": "^3.2.4",
     "@vitest/coverage-v8": "^3.2.4",
     "autoprefixer": "^10.4.22",
     "autoprefixer": "^10.4.22",
-    "baseline-browser-mapping": "^2.9.18",
+    "baseline-browser-mapping": "^2.9.19",
     "eslint": "^9.39.1",
     "eslint": "^9.39.1",
     "eslint-plugin-react-hooks": "^7.0.1",
     "eslint-plugin-react-hooks": "^7.0.1",
     "eslint-plugin-react-refresh": "^0.4.24",
     "eslint-plugin-react-refresh": "^0.4.24",

+ 8 - 4
frontend/src/App.tsx

@@ -12,11 +12,11 @@ import { ProjectsPage } from './pages/ProjectsPage';
 import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { ProjectDetailPage } from './pages/ProjectDetailPage';
 import { FileManagerPage } from './pages/FileManagerPage';
 import { FileManagerPage } from './pages/FileManagerPage';
 import { CameraPage } from './pages/CameraPage';
 import { CameraPage } from './pages/CameraPage';
+import { StreamOverlayPage } from './pages/StreamOverlayPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
 import { LoginPage } from './pages/LoginPage';
 import { SetupPage } from './pages/SetupPage';
 import { SetupPage } from './pages/SetupPage';
-import { UsersPage } from './pages/UsersPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -51,7 +51,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
 }
 }
 
 
 function AdminRoute({ children }: { children: React.ReactNode }) {
 function AdminRoute({ children }: { children: React.ReactNode }) {
-  const { authEnabled, loading, user } = useAuth();
+  const { authEnabled, loading, user, isAdmin } = useAuth();
 
 
   if (loading) {
   if (loading) {
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
@@ -68,7 +68,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
   }
   }
 
 
   // If user is not admin, redirect to home
   // If user is not admin, redirect to home
-  if (user.role !== 'admin') {
+  if (!isAdmin) {
     return <Navigate to="/" replace />;
     return <Navigate to="/" replace />;
   }
   }
 
 
@@ -109,6 +109,9 @@ function App() {
                 {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
                 {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
                 <Route path="/camera/:printerId" element={<CameraPage />} />
                 <Route path="/camera/:printerId" element={<CameraPage />} />
 
 
+                {/* Stream overlay page - standalone for OBS/streaming embeds, no auth required */}
+                <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
+
                 {/* Main app with WebSocket for real-time updates */}
                 {/* Main app with WebSocket for real-time updates */}
                 <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
                 <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
                   <Route index element={<PrintersPage />} />
                   <Route index element={<PrintersPage />} />
@@ -121,7 +124,8 @@ function App() {
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
                   <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
-                  <Route path="users" element={<UsersPage />} />
+                  <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
+                  <Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="system" element={<SystemInfoPage />} />
                   <Route path="system" element={<SystemInfoPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                 </Route>
                 </Route>

+ 5 - 1
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -38,7 +38,11 @@ describe('EditArchiveModal', () => {
         return HttpResponse.json(mockProjects);
         return HttpResponse.json(mockProjects);
       }),
       }),
       http.get('/api/v1/archives/tags', () => {
       http.get('/api/v1/archives/tags', () => {
-        return HttpResponse.json(['test', 'calibration', 'functional']);
+        return HttpResponse.json([
+          { name: 'test', count: 2 },
+          { name: 'calibration', count: 1 },
+          { name: 'functional', count: 3 },
+        ]);
       }),
       }),
       http.patch('/api/v1/archives/:id', async ({ request }) => {
       http.patch('/api/v1/archives/:id', async ({ request }) => {
         const body = await request.json();
         const body = await request.json();

+ 87 - 0
frontend/src/__tests__/components/SmartPlugCard.test.tsx

@@ -21,6 +21,24 @@ const createMockPlug = (overrides: Partial<SmartPlug> = {}): SmartPlug => ({
   plug_type: 'tasmota',
   plug_type: 'tasmota',
   ip_address: '192.168.1.100',
   ip_address: '192.168.1.100',
   ha_entity_id: null,
   ha_entity_id: null,
+  ha_power_entity: null,
+  ha_energy_today_entity: null,
+  ha_energy_total_entity: null,
+  // MQTT fields (legacy)
+  mqtt_topic: null,
+  mqtt_multiplier: 1.0,
+  // MQTT power fields
+  mqtt_power_topic: null,
+  mqtt_power_path: null,
+  mqtt_power_multiplier: 1.0,
+  // MQTT energy fields
+  mqtt_energy_topic: null,
+  mqtt_energy_path: null,
+  mqtt_energy_multiplier: 1.0,
+  // MQTT state fields
+  mqtt_state_topic: null,
+  mqtt_state_path: null,
+  mqtt_state_on_value: null,
   printer_id: 1,
   printer_id: 1,
   enabled: true,
   enabled: true,
   auto_on: true,
   auto_on: true,
@@ -272,4 +290,73 @@ describe('SmartPlugCard', () => {
       expect(buttons.length).toBeGreaterThan(0);
       expect(buttons.length).toBeGreaterThan(0);
     });
     });
   });
   });
+
+  describe('MQTT plugs', () => {
+    it('renders MQTT plug with topic instead of IP', () => {
+      const plug = createMockPlug({
+        plug_type: 'mqtt',
+        ip_address: null,
+        mqtt_topic: 'zigbee2mqtt/shelly-power',
+        mqtt_power_path: 'power_l1',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      // Should show topic, not IP
+      expect(screen.getByText('zigbee2mqtt/shelly-power')).toBeInTheDocument();
+      expect(screen.queryByText('192.168.1.100')).not.toBeInTheDocument();
+    });
+
+    it('renders MQTT plug name correctly', () => {
+      const plug = createMockPlug({
+        name: 'MQTT Energy Monitor',
+        plug_type: 'mqtt',
+        ip_address: null,
+        mqtt_topic: 'sensors/power',
+        mqtt_power_path: 'power',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      expect(screen.getByText('MQTT Energy Monitor')).toBeInTheDocument();
+    });
+
+    it('shows Monitor Only badge for MQTT plug', () => {
+      const plug = createMockPlug({
+        plug_type: 'mqtt',
+        ip_address: null,
+        mqtt_topic: 'test/topic',
+        mqtt_power_path: 'power',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      expect(screen.getByText('Monitor Only')).toBeInTheDocument();
+    });
+
+    it('does not show power control buttons for MQTT plug', () => {
+      const plug = createMockPlug({
+        plug_type: 'mqtt',
+        ip_address: null,
+        mqtt_topic: 'test/topic',
+        mqtt_power_path: 'power',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      // On/Off buttons should not be present for monitor-only plugs
+      expect(screen.queryByRole('button', { name: /^on$/i })).not.toBeInTheDocument();
+      expect(screen.queryByRole('button', { name: /^off$/i })).not.toBeInTheDocument();
+    });
+
+    it('shows Settings instead of Automation Settings for MQTT plug', async () => {
+      const plug = createMockPlug({
+        plug_type: 'mqtt',
+        ip_address: null,
+        mqtt_topic: 'test/topic',
+        mqtt_power_path: 'power',
+      });
+      render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
+
+      // Should show "Settings" not "Automation Settings"
+      expect(screen.getByText('Settings')).toBeInTheDocument();
+      expect(screen.queryByText('Automation Settings')).not.toBeInTheDocument();
+    });
+  });
 });
 });

+ 300 - 0
frontend/src/__tests__/components/TagManagementModal.test.tsx

@@ -0,0 +1,300 @@
+/**
+ * Tests for the TagManagementModal component.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { TagManagementModal } from '../../components/TagManagementModal';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockTags = [
+  { name: 'functional', count: 5 },
+  { name: 'calibration', count: 3 },
+  { name: 'test', count: 2 },
+  { name: 'art', count: 1 },
+];
+
+describe('TagManagementModal', () => {
+  const mockOnClose = vi.fn();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/archives/tags', () => {
+        return HttpResponse.json(mockTags);
+      }),
+      http.put('/api/v1/archives/tags/:tagName', async () => {
+        return HttpResponse.json({ affected: 2 });
+      }),
+      http.delete('/api/v1/archives/tags/:tagName', () => {
+        return HttpResponse.json({ affected: 1 });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the modal title', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      expect(screen.getByText('Manage Tags')).toBeInTheDocument();
+    });
+
+    it('shows loading state initially', () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      // Should show loading spinner before data loads
+      expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
+    });
+
+    it('displays tags with counts', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+        expect(screen.getByText('5')).toBeInTheDocument();
+        expect(screen.getByText('calibration')).toBeInTheDocument();
+        expect(screen.getByText('3')).toBeInTheDocument();
+      });
+    });
+
+    it('shows total tag count and usage', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        // 4 tags, 11 total usages
+        expect(screen.getByText(/4 tags across 11 usages/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('search functionality', () => {
+    it('filters tags by search input', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const searchInput = screen.getByPlaceholderText('Search tags...');
+      await user.type(searchInput, 'cal');
+
+      await waitFor(() => {
+        expect(screen.getByText('calibration')).toBeInTheDocument();
+        expect(screen.queryByText('functional')).not.toBeInTheDocument();
+        expect(screen.queryByText('art')).not.toBeInTheDocument();
+      });
+    });
+
+    it('shows no results message when search has no matches', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const searchInput = screen.getByPlaceholderText('Search tags...');
+      await user.type(searchInput, 'nonexistent');
+
+      await waitFor(() => {
+        expect(screen.getByText('No tags match your search')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('sorting', () => {
+    it('sorts by count by default', async () => {
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        const tagElements = screen.getAllByText(/functional|calibration|test|art/);
+        // First should be functional (count 5)
+        expect(tagElements[0]).toHaveTextContent('functional');
+      });
+    });
+
+    it('can sort by name', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const sortSelect = screen.getByDisplayValue('Sort by Count');
+      await user.selectOptions(sortSelect, 'name');
+
+      await waitFor(() => {
+        const tagElements = screen.getAllByText(/functional|calibration|test|art/);
+        // First should be 'art' alphabetically
+        expect(tagElements[0]).toHaveTextContent('art');
+      });
+    });
+  });
+
+  describe('rename functionality', () => {
+    it('enters edit mode when clicking edit button', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      // Find the tag row and click its edit button
+      const tagRow = screen.getByText('functional').closest('div');
+      const editButton = within(tagRow!).getByTitle('Rename tag');
+      await user.click(editButton);
+
+      // Should show input with current value
+      await waitFor(() => {
+        expect(screen.getByDisplayValue('functional')).toBeInTheDocument();
+      });
+    });
+
+    it('submits rename on Enter key', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const editButton = within(tagRow!).getByTitle('Rename tag');
+      await user.click(editButton);
+
+      const input = screen.getByDisplayValue('functional');
+      await user.clear(input);
+      await user.type(input, 'new-name{Enter}');
+
+      // Should show success (mutation called)
+      await waitFor(() => {
+        // After successful rename, edit mode should close
+        expect(screen.queryByDisplayValue('new-name')).not.toBeInTheDocument();
+      });
+    });
+
+    it('cancels edit on Escape key', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const editButton = within(tagRow!).getByTitle('Rename tag');
+      await user.click(editButton);
+
+      const input = screen.getByDisplayValue('functional');
+      await user.type(input, '-modified{Escape}');
+
+      // Should exit edit mode without saving
+      await waitFor(() => {
+        expect(screen.queryByDisplayValue('functional-modified')).not.toBeInTheDocument();
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('delete functionality', () => {
+    it('shows delete confirmation when clicking delete button', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const deleteButton = within(tagRow!).getByTitle('Delete tag');
+      await user.click(deleteButton);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Delete "functional" from 5 archives?/i)).toBeInTheDocument();
+      });
+    });
+
+    it('cancels delete confirmation on X button', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+
+      const tagRow = screen.getByText('functional').closest('div');
+      const deleteButton = within(tagRow!).getByTitle('Delete tag');
+      await user.click(deleteButton);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Delete "functional"/i)).toBeInTheDocument();
+      });
+
+      // Find the confirmation row and click the cancel (X) button within it
+      const confirmationText = screen.getByText(/Delete "functional"/i);
+      const confirmationRow = confirmationText.closest('div');
+      // The X button is the last button in the confirmation row
+      const buttons = within(confirmationRow!.parentElement!).getAllByRole('button');
+      const cancelButton = buttons[buttons.length - 1]; // X button is last
+      await user.click(cancelButton);
+
+      await waitFor(() => {
+        // Should return to normal display - the tag name should be visible again
+        expect(screen.getByText('functional')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('modal behavior', () => {
+    it('calls onClose when close button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Manage Tags')).toBeInTheDocument();
+      });
+
+      // Find close button in header (X icon)
+      const headerCloseButton = screen.getAllByRole('button')[0];
+      await user.click(headerCloseButton);
+
+      expect(mockOnClose).toHaveBeenCalledTimes(1);
+    });
+
+    it('calls onClose when Close button is clicked', async () => {
+      const user = userEvent.setup();
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Manage Tags')).toBeInTheDocument();
+      });
+
+      const closeButton = screen.getByRole('button', { name: /close/i });
+      await user.click(closeButton);
+
+      expect(mockOnClose).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('empty state', () => {
+    it('shows empty message when no tags exist', async () => {
+      server.use(
+        http.get('/api/v1/archives/tags', () => {
+          return HttpResponse.json([]);
+        })
+      );
+
+      render(<TagManagementModal onClose={mockOnClose} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('No tags found')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 169 - 0
frontend/src/__tests__/contexts/AuthContext.test.tsx

@@ -0,0 +1,169 @@
+/**
+ * Tests for the AuthContext permission helpers.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { BrowserRouter } from 'react-router-dom';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { AuthProvider, useAuth } from '../../contexts/AuthContext';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+import type { Permission } from '../../api/client';
+
+function createWrapper() {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: { retry: false },
+    },
+  });
+
+  return function Wrapper({ children }: { children: React.ReactNode }) {
+    return (
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <ThemeProvider>
+            <ToastProvider>
+              <AuthProvider>{children}</AuthProvider>
+            </ToastProvider>
+          </ThemeProvider>
+        </BrowserRouter>
+      </QueryClientProvider>
+    );
+  };
+}
+
+describe('AuthContext', () => {
+  describe('when auth is disabled', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/auth/status', () => {
+          return HttpResponse.json({
+            auth_enabled: false,
+            requires_setup: false,
+          });
+        })
+      );
+    });
+
+    it('authEnabled is false', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.authEnabled).toBe(false);
+      });
+    });
+
+    it('hasPermission returns true for any permission when auth disabled', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.authEnabled).toBe(false);
+      });
+
+      // When auth is disabled, all permissions should be granted
+      expect(result.current.hasPermission('printers:read' as Permission)).toBe(true);
+      expect(result.current.hasPermission('settings:update' as Permission)).toBe(true);
+      expect(result.current.hasPermission('users:delete' as Permission)).toBe(true);
+    });
+
+    it('hasAnyPermission returns true for any permissions when auth disabled', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.authEnabled).toBe(false);
+      });
+
+      expect(
+        result.current.hasAnyPermission('printers:read' as Permission, 'settings:update' as Permission)
+      ).toBe(true);
+    });
+
+    it('hasAllPermissions returns true for any permissions when auth disabled', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.authEnabled).toBe(false);
+      });
+
+      expect(
+        result.current.hasAllPermissions('printers:read' as Permission, 'settings:update' as Permission)
+      ).toBe(true);
+    });
+  });
+
+  describe('when auth requires setup', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/auth/status', () => {
+          return HttpResponse.json({
+            auth_enabled: false,
+            requires_setup: true,
+          });
+        })
+      );
+    });
+
+    it('requiresSetup is true', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.requiresSetup).toBe(true);
+      });
+    });
+  });
+
+  describe('when auth is enabled but not logged in', () => {
+    beforeEach(() => {
+      // Clear any stored token
+      localStorage.removeItem('auth_token');
+
+      server.use(
+        http.get('/api/v1/auth/status', () => {
+          return HttpResponse.json({
+            auth_enabled: true,
+            requires_setup: false,
+          });
+        })
+      );
+    });
+
+    it('user is null when not logged in', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.authEnabled).toBe(true);
+      });
+
+      // User should be null when not logged in
+      expect(result.current.user).toBeNull();
+    });
+
+    it('hasPermission returns false when not logged in', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.authEnabled).toBe(true);
+      });
+
+      // Without a user, permissions should be denied
+      expect(result.current.hasPermission('printers:read' as Permission)).toBe(false);
+    });
+  });
+});

+ 84 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -280,6 +280,90 @@ export const handlers = [
     });
     });
   }),
   }),
 
 
+  http.get('/api/v1/auth/me', () => {
+    return HttpResponse.json({
+      id: 1,
+      username: 'admin',
+      role: 'admin',
+      is_active: true,
+      is_admin: true,
+      groups: [{ id: 1, name: 'Administrators' }],
+      permissions: [],
+      created_at: '2024-01-01T00:00:00Z',
+    });
+  }),
+
+  // ========================================================================
+  // Groups
+  // ========================================================================
+
+  http.get('/api/v1/groups/', () => {
+    return HttpResponse.json([
+      {
+        id: 1,
+        name: 'Administrators',
+        description: 'Full access to all features',
+        permissions: ['printers:read', 'settings:update', 'users:create'],
+        is_system: true,
+        created_at: '2024-01-01T00:00:00Z',
+        updated_at: '2024-01-01T00:00:00Z',
+      },
+      {
+        id: 2,
+        name: 'Operators',
+        description: 'Control printers and manage content',
+        permissions: ['printers:read', 'printers:control'],
+        is_system: true,
+        created_at: '2024-01-01T00:00:00Z',
+        updated_at: '2024-01-01T00:00:00Z',
+      },
+      {
+        id: 3,
+        name: 'Viewers',
+        description: 'Read-only access',
+        permissions: ['printers:read'],
+        is_system: true,
+        created_at: '2024-01-01T00:00:00Z',
+        updated_at: '2024-01-01T00:00:00Z',
+      },
+    ]);
+  }),
+
+  http.get('/api/v1/groups/permissions', () => {
+    return HttpResponse.json({
+      'Printers': ['printers:read', 'printers:create', 'printers:update', 'printers:delete', 'printers:control'],
+      'Archives': ['archives:read', 'archives:create', 'archives:update', 'archives:delete'],
+      'Settings': ['settings:read', 'settings:update'],
+    });
+  }),
+
+  http.post('/api/v1/groups/', async ({ request }) => {
+    const body = (await request.json()) as Record<string, unknown>;
+    return HttpResponse.json({
+      id: 4,
+      ...body,
+      is_system: false,
+      created_at: '2024-01-01T00:00:00Z',
+      updated_at: '2024-01-01T00:00:00Z',
+    });
+  }),
+
+  http.patch('/api/v1/groups/:id', async ({ params, request }) => {
+    const body = (await request.json()) as Record<string, unknown>;
+    return HttpResponse.json({
+      id: Number(params.id),
+      name: 'Updated Group',
+      ...body,
+      is_system: false,
+      created_at: '2024-01-01T00:00:00Z',
+      updated_at: '2024-01-01T00:00:00Z',
+    });
+  }),
+
+  http.delete('/api/v1/groups/:id', () => {
+    return new HttpResponse(null, { status: 204 });
+  }),
+
   // ========================================================================
   // ========================================================================
   // Version / Health
   // Version / Health
   // ========================================================================
   // ========================================================================

+ 198 - 2
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -347,8 +347,8 @@ describe('FileManagerPage', () => {
       render(<FileManagerPage />);
       render(<FileManagerPage />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        // Sort dropdown should show Date as default option
-        expect(screen.getByDisplayValue('Date')).toBeInTheDocument();
+        // Sort dropdown should show Name as default option (persisted to localStorage)
+        expect(screen.getByDisplayValue('Name')).toBeInTheDocument();
       });
       });
     });
     });
   });
   });
@@ -476,4 +476,200 @@ describe('FileManagerPage', () => {
       });
       });
     });
     });
   });
   });
+
+  describe('STL thumbnail generation', () => {
+    it('shows Generate Thumbnails button', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
+      });
+    });
+
+    it('Generate Thumbnails button has correct title', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        const button = screen.getByTitle('Generate thumbnails for STL files missing them');
+        expect(button).toBeInTheDocument();
+      });
+    });
+
+    it('can click Generate Thumbnails button', async () => {
+      const user = userEvent.setup();
+
+      server.use(
+        http.post('/api/v1/library/generate-stl-thumbnails', () => {
+          return HttpResponse.json({
+            processed: 1,
+            succeeded: 1,
+            failed: 0,
+            results: [{ file_id: 2, success: true }],
+          });
+        })
+      );
+
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
+      });
+
+      const button = screen.getByText('Generate Thumbnails');
+      await user.click(button);
+
+      // Button should work without error
+      await waitFor(() => {
+        expect(screen.getByText('Generate Thumbnails')).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL file without thumbnail in file list', async () => {
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        // bracket.stl has no thumbnail_path
+        expect(screen.getByText('bracket.stl')).toBeInTheDocument();
+        expect(screen.getAllByText('STL').length).toBeGreaterThan(0);
+      });
+    });
+  });
+
+  describe('upload modal STL options', () => {
+    it('opens upload modal', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+        expect(screen.getByText(/Drag & drop/)).toBeInTheDocument();
+      });
+    });
+
+    it('shows STL thumbnail option when STL file is added', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      // Create a mock STL file
+      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
+
+      // Get the hidden file input
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      expect(fileInput).toBeInTheDocument();
+
+      // Simulate file selection
+      await user.upload(fileInput, stlFile);
+
+      // STL thumbnail option should appear
+      await waitFor(() => {
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+      });
+    });
+
+    it('STL thumbnail checkbox is checked by default', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      // Add an STL file
+      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, stlFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+      });
+
+      // Checkbox should be checked by default
+      const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i });
+      expect(checkbox).toBeChecked();
+    });
+
+    it('can toggle STL thumbnail checkbox', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      // Add an STL file
+      const stlFile = new File(['solid test'], 'model.stl', { type: 'application/sla' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, stlFile);
+
+      await waitFor(() => {
+        expect(screen.getByText('Generate thumbnails for STL files')).toBeInTheDocument();
+      });
+
+      const checkbox = screen.getByRole('checkbox', { name: /Generate thumbnails for STL files/i });
+      expect(checkbox).toBeChecked();
+
+      // Toggle off
+      await user.click(checkbox);
+      expect(checkbox).not.toBeChecked();
+
+      // Toggle back on
+      await user.click(checkbox);
+      expect(checkbox).toBeChecked();
+    });
+
+    it('shows STL thumbnail option for ZIP files', async () => {
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Upload'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Upload Files')).toBeInTheDocument();
+      });
+
+      // Create a mock ZIP file
+      const zipFile = new File(['PK'], 'models.zip', { type: 'application/zip' });
+      const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
+      await user.upload(fileInput, zipFile);
+
+      // STL thumbnail option should appear for ZIP files (may contain STLs)
+      await waitFor(() => {
+        expect(screen.getByText('STL thumbnail generation')).toBeInTheDocument();
+        expect(screen.getByText(/ZIP files may contain STL files/)).toBeInTheDocument();
+      });
+    });
+  });
 });
 });

+ 136 - 0
frontend/src/__tests__/pages/GroupsPage.test.tsx

@@ -0,0 +1,136 @@
+/**
+ * Tests for the GroupsPage component.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { GroupsPage } from '../../pages/GroupsPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockGroups = [
+  {
+    id: 1,
+    name: 'Administrators',
+    description: 'Full access to all features',
+    permissions: ['printers:read', 'printers:control', 'settings:read', 'settings:update', 'users:read', 'users:create'],
+    is_system: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+  {
+    id: 2,
+    name: 'Operators',
+    description: 'Control printers and manage content',
+    permissions: ['printers:read', 'printers:control', 'archives:read', 'queue:read', 'queue:create'],
+    is_system: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+  {
+    id: 3,
+    name: 'Viewers',
+    description: 'Read-only access',
+    permissions: ['printers:read', 'archives:read', 'queue:read'],
+    is_system: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const mockPermissions = {
+  'Printers': ['printers:read', 'printers:create', 'printers:update', 'printers:delete', 'printers:control'],
+  'Archives': ['archives:read', 'archives:create', 'archives:update', 'archives:delete'],
+  'Queue': ['queue:read', 'queue:create', 'queue:update', 'queue:delete'],
+  'Settings': ['settings:read', 'settings:update'],
+  'Users': ['users:read', 'users:create', 'users:update', 'users:delete'],
+};
+
+describe('GroupsPage', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/groups/', () => {
+        return HttpResponse.json(mockGroups);
+      }),
+      http.get('/api/v1/groups/permissions', () => {
+        return HttpResponse.json(mockPermissions);
+      }),
+      http.get('/api/v1/auth/status', () => {
+        return HttpResponse.json({
+          auth_enabled: false,
+          requires_setup: false,
+        });
+      }),
+      http.get('/api/v1/users/', () => {
+        return HttpResponse.json([]);
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the page', async () => {
+      render(<GroupsPage />);
+
+      // Page should render without errors
+      await waitFor(() => {
+        expect(document.body).toBeInTheDocument();
+      });
+    });
+
+    it('renders group names from API', async () => {
+      render(<GroupsPage />);
+
+      await waitFor(() => {
+        // Check that the groups are rendered
+        expect(document.body.textContent).toContain('Administrators');
+        expect(document.body.textContent).toContain('Operators');
+        expect(document.body.textContent).toContain('Viewers');
+      });
+    });
+
+    it('shows group descriptions', async () => {
+      render(<GroupsPage />);
+
+      await waitFor(() => {
+        expect(document.body.textContent).toContain('Full access to all features');
+      });
+    });
+  });
+
+  describe('API integration', () => {
+    it('fetches groups on mount', async () => {
+      let groupsFetched = false;
+
+      server.use(
+        http.get('/api/v1/groups/', () => {
+          groupsFetched = true;
+          return HttpResponse.json(mockGroups);
+        })
+      );
+
+      render(<GroupsPage />);
+
+      await waitFor(() => {
+        expect(groupsFetched).toBe(true);
+      });
+    });
+
+    it('fetches permissions on mount', async () => {
+      let permissionsFetched = false;
+
+      server.use(
+        http.get('/api/v1/groups/permissions', () => {
+          permissionsFetched = true;
+          return HttpResponse.json(mockPermissions);
+        })
+      );
+
+      render(<GroupsPage />);
+
+      await waitFor(() => {
+        expect(permissionsFetched).toBe(true);
+      });
+    });
+  });
+});

+ 9 - 4
frontend/src/__tests__/pages/LoginPage.test.tsx

@@ -119,11 +119,13 @@ describe('LoginPage', () => {
 
 
     it('shows loading state during login', async () => {
     it('shows loading state during login', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();
+      let resolveLogin: () => void;
+      const loginPromise = new Promise<void>(resolve => { resolveLogin = resolve; });
 
 
-      // Slow login endpoint
+      // Slow login endpoint that we control
       server.use(
       server.use(
         http.post('/api/v1/auth/login', async () => {
         http.post('/api/v1/auth/login', async () => {
-          await new Promise(resolve => setTimeout(resolve, 100));
+          await loginPromise;
           return HttpResponse.json({
           return HttpResponse.json({
             access_token: 'test-token',
             access_token: 'test-token',
             token_type: 'bearer',
             token_type: 'bearer',
@@ -148,10 +150,13 @@ describe('LoginPage', () => {
       await user.type(screen.getByLabelText(/Password/i), 'testpass');
       await user.type(screen.getByLabelText(/Password/i), 'testpass');
       await user.click(screen.getByRole('button', { name: /Sign in/i }));
       await user.click(screen.getByRole('button', { name: /Sign in/i }));
 
 
-      // Check for loading state
+      // Check for loading state - button text should change to "Logging in..."
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByRole('button')).toBeDisabled();
+        expect(screen.getByRole('button', { name: /Logging in/i })).toBeInTheDocument();
       });
       });
+
+      // Release the login request
+      resolveLogin!();
     });
     });
   });
   });
 });
 });

+ 230 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -0,0 +1,230 @@
+/**
+ * Tests for the StreamOverlayPage component.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor, render as rtlRender } from '@testing-library/react';
+import { StreamOverlayPage } from '../../pages/StreamOverlayPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ThemeProvider } from '../../contexts/ThemeContext';
+import { ToastProvider } from '../../contexts/ToastContext';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1 Carbon',
+  ip_address: '192.168.1.100',
+  serial_number: '00M09A350100001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+};
+
+const mockStatusIdle = {
+  id: 1,
+  name: 'X1 Carbon',
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  current_print: null,
+  remaining_time: null,
+  layer_num: null,
+  total_layers: null,
+  stg_cur_name: null,
+};
+
+const mockStatusPrinting = {
+  id: 1,
+  name: 'X1 Carbon',
+  connected: true,
+  state: 'RUNNING',
+  progress: 45,
+  current_print: 'Benchy.gcode.3mf',
+  remaining_time: 82,
+  layer_num: 150,
+  total_layers: 300,
+  stg_cur_name: null,
+};
+
+// Custom render for StreamOverlayPage
+function renderOverlayPage(printerId: number, queryParams = '') {
+  const queryClient = new QueryClient({
+    defaultOptions: {
+      queries: { retry: false, gcTime: 0 },
+      mutations: { retry: false },
+    },
+  });
+
+  return rtlRender(
+    <QueryClientProvider client={queryClient}>
+      <MemoryRouter initialEntries={[`/overlay/${printerId}${queryParams}`]}>
+        <ThemeProvider>
+          <ToastProvider>
+            <Routes>
+              <Route path="/overlay/:printerId" element={<StreamOverlayPage />} />
+            </Routes>
+          </ToastProvider>
+        </ThemeProvider>
+      </MemoryRouter>
+    </QueryClientProvider>
+  );
+}
+
+describe('StreamOverlayPage', () => {
+  const originalTitle = document.title;
+
+  beforeEach(() => {
+    // Mock WebSocket
+    vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => ({
+      close: vi.fn(),
+      onmessage: null,
+      onerror: null,
+    })));
+
+    server.use(
+      http.get('/api/v1/printers/:id', () => {
+        return HttpResponse.json(mockPrinter);
+      }),
+      http.get('/api/v1/printers/:id/status', () => {
+        return HttpResponse.json(mockStatusIdle);
+      })
+    );
+  });
+
+  afterEach(() => {
+    document.title = originalTitle;
+    vi.unstubAllGlobals();
+  });
+
+  describe('rendering', () => {
+    it('renders overlay page for printer', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Bambuddy logo', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
+      });
+    });
+
+    it('logo links to GitHub', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        const logo = screen.getByAltText('Bambuddy');
+        const link = logo.closest('a');
+        expect(link).toHaveAttribute('href', 'https://github.com/maziggy/bambuddy');
+      });
+    });
+  });
+
+  describe('printing state', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockStatusPrinting);
+        })
+      );
+    });
+
+    it('shows filename when printing', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Benchy')).toBeInTheDocument();
+      });
+    });
+
+    it('shows progress percentage', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('45%')).toBeInTheDocument();
+      });
+    });
+
+    it('shows layer count', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('150')).toBeInTheDocument();
+        expect(screen.getByText('300')).toBeInTheDocument();
+      });
+    });
+
+    it('shows status text', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printing')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('invalid printer', () => {
+    it('shows invalid printer message for ID 0', async () => {
+      renderOverlayPage(0);
+
+      await waitFor(() => {
+        expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('query parameters', () => {
+    it('respects size parameter', async () => {
+      renderOverlayPage(1, '?size=large');
+
+      await waitFor(() => {
+        // Just verify it renders without error
+        expect(screen.getByAltText('Bambuddy')).toBeInTheDocument();
+      });
+    });
+
+    it('respects show parameter to hide elements', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json(mockStatusPrinting);
+        })
+      );
+
+      renderOverlayPage(1, '?show=progress');
+
+      await waitFor(() => {
+        // Progress should be visible
+        expect(screen.getByText('45%')).toBeInTheDocument();
+        // Status text should be hidden when not in show list
+        expect(screen.queryByText('Printing')).not.toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('offline state', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () => {
+          return HttpResponse.json({
+            ...mockStatusIdle,
+            connected: false,
+          });
+        })
+      );
+    });
+
+    it('shows offline message when printer disconnected', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer offline')).toBeInTheDocument();
+      });
+    });
+  });
+});

+ 232 - 14
frontend/src/api/client.ts

@@ -50,6 +50,12 @@ async function request<T>(
     throw new Error(message);
     throw new Error(message);
   }
   }
 
 
+  // Handle empty responses (204 No Content, etc.)
+  const contentLength = response.headers.get('content-length');
+  if (response.status === 204 || contentLength === '0') {
+    return undefined as T;
+  }
+
   return await response.json();
   return await response.json();
 }
 }
 
 
@@ -335,6 +341,11 @@ export interface ArchiveStats {
   total_energy_cost: number;
   total_energy_cost: number;
 }
 }
 
 
+export interface TagInfo {
+  name: string;
+  count: number;
+}
+
 export interface FailureAnalysis {
 export interface FailureAnalysis {
   period_days: number;
   period_days: number;
   total_prints: number;
   total_prints: number;
@@ -841,13 +852,29 @@ export interface CloudDevice {
 export interface SmartPlug {
 export interface SmartPlug {
   id: number;
   id: number;
   name: string;
   name: string;
-  plug_type: 'tasmota' | 'homeassistant';
+  plug_type: 'tasmota' | 'homeassistant' | 'mqtt';
   ip_address: string | null;  // Required for Tasmota
   ip_address: string | null;  // Required for Tasmota
-  ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug")
+  ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
   // Home Assistant energy sensor entities (optional)
   // Home Assistant energy sensor entities (optional)
   ha_power_entity: string | null;
   ha_power_entity: string | null;
   ha_energy_today_entity: string | null;
   ha_energy_today_entity: string | null;
   ha_energy_total_entity: string | null;
   ha_energy_total_entity: string | null;
+  // MQTT fields (required when plug_type="mqtt")
+  // Legacy field - kept for backward compatibility
+  mqtt_topic: string | null;  // Deprecated, use mqtt_power_topic
+  mqtt_multiplier: number;  // Deprecated, use mqtt_power_multiplier
+  // Power monitoring
+  mqtt_power_topic: string | null;  // Topic for power data
+  mqtt_power_path: string | null;  // e.g., "power_l1" or "data.power"
+  mqtt_power_multiplier: number;  // Unit conversion for power
+  // Energy monitoring
+  mqtt_energy_topic: string | null;  // Topic for energy data
+  mqtt_energy_path: string | null;  // e.g., "energy_l1"
+  mqtt_energy_multiplier: number;  // Unit conversion for energy
+  // State monitoring
+  mqtt_state_topic: string | null;  // Topic for state data
+  mqtt_state_path: string | null;  // e.g., "state_l1" for ON/OFF
+  mqtt_state_on_value: string | null;  // What value means "ON" (e.g., "ON", "true", "1")
   printer_id: number | null;
   printer_id: number | null;
   enabled: boolean;
   enabled: boolean;
   auto_on: boolean;
   auto_on: boolean;
@@ -866,8 +893,9 @@ export interface SmartPlug {
   schedule_enabled: boolean;
   schedule_enabled: boolean;
   schedule_on_time: string | null;
   schedule_on_time: string | null;
   schedule_off_time: string | null;
   schedule_off_time: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar: boolean;
   show_in_switchbar: boolean;
+  show_on_printer_card: boolean;  // For scripts: show on printer card
   // Status
   // Status
   last_state: string | null;
   last_state: string | null;
   last_checked: string | null;
   last_checked: string | null;
@@ -878,13 +906,29 @@ export interface SmartPlug {
 
 
 export interface SmartPlugCreate {
 export interface SmartPlugCreate {
   name: string;
   name: string;
-  plug_type?: 'tasmota' | 'homeassistant';
+  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
   ip_address?: string | null;  // Required for Tasmota
   ip_address?: string | null;  // Required for Tasmota
   ha_entity_id?: string | null;  // Required for Home Assistant
   ha_entity_id?: string | null;  // Required for Home Assistant
   // Home Assistant energy sensor entities (optional)
   // Home Assistant energy sensor entities (optional)
   ha_power_entity?: string | null;
   ha_power_entity?: string | null;
   ha_energy_today_entity?: string | null;
   ha_energy_today_entity?: string | null;
   ha_energy_total_entity?: string | null;
   ha_energy_total_entity?: string | null;
+  // MQTT fields (required when plug_type="mqtt")
+  // Legacy fields - kept for backward compatibility
+  mqtt_topic?: string | null;
+  mqtt_multiplier?: number;
+  // Power monitoring
+  mqtt_power_topic?: string | null;
+  mqtt_power_path?: string | null;
+  mqtt_power_multiplier?: number;
+  // Energy monitoring
+  mqtt_energy_topic?: string | null;
+  mqtt_energy_path?: string | null;
+  mqtt_energy_multiplier?: number;
+  // State monitoring
+  mqtt_state_topic?: string | null;
+  mqtt_state_path?: string | null;
+  mqtt_state_on_value?: string | null;
   printer_id?: number | null;
   printer_id?: number | null;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
@@ -902,19 +946,35 @@ export interface SmartPlugCreate {
   schedule_enabled?: boolean;
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 }
 
 
 export interface SmartPlugUpdate {
 export interface SmartPlugUpdate {
   name?: string;
   name?: string;
-  plug_type?: 'tasmota' | 'homeassistant';
+  plug_type?: 'tasmota' | 'homeassistant' | 'mqtt';
   ip_address?: string | null;
   ip_address?: string | null;
   ha_entity_id?: string | null;
   ha_entity_id?: string | null;
   // Home Assistant energy sensor entities (optional)
   // Home Assistant energy sensor entities (optional)
   ha_power_entity?: string | null;
   ha_power_entity?: string | null;
   ha_energy_today_entity?: string | null;
   ha_energy_today_entity?: string | null;
   ha_energy_total_entity?: string | null;
   ha_energy_total_entity?: string | null;
+  // MQTT fields (legacy)
+  mqtt_topic?: string | null;
+  mqtt_multiplier?: number;
+  // MQTT power fields
+  mqtt_power_topic?: string | null;
+  mqtt_power_path?: string | null;
+  mqtt_power_multiplier?: number;
+  // MQTT energy fields
+  mqtt_energy_topic?: string | null;
+  mqtt_energy_path?: string | null;
+  mqtt_energy_multiplier?: number;
+  // MQTT state fields
+  mqtt_state_topic?: string | null;
+  mqtt_state_path?: string | null;
+  mqtt_state_on_value?: string | null;
   printer_id?: number | null;
   printer_id?: number | null;
   enabled?: boolean;
   enabled?: boolean;
   auto_on?: boolean;
   auto_on?: boolean;
@@ -932,8 +992,9 @@ export interface SmartPlugUpdate {
   schedule_enabled?: boolean;
   schedule_enabled?: boolean;
   schedule_on_time?: string | null;
   schedule_on_time?: string | null;
   schedule_off_time?: string | null;
   schedule_off_time?: string | null;
-  // Switchbar visibility
+  // Visibility options
   show_in_switchbar?: boolean;
   show_in_switchbar?: boolean;
+  show_on_printer_card?: boolean;
 }
 }
 
 
 // Home Assistant entity for smart plug selection
 // Home Assistant entity for smart plug selection
@@ -941,7 +1002,7 @@ export interface HAEntity {
   entity_id: string;
   entity_id: string;
   friendly_name: string;
   friendly_name: string;
   state: string | null;
   state: string | null;
-  domain: string;  // "switch", "light", "input_boolean"
+  domain: string;  // "switch", "light", "input_boolean", "script"
 }
 }
 
 
 // Home Assistant sensor entity for energy monitoring
 // Home Assistant sensor entity for energy monitoring
@@ -1658,6 +1719,82 @@ export interface ExternalLinkUpdate {
   icon?: string;
   icon?: string;
 }
 }
 
 
+// Permission type - all available permissions
+export type Permission =
+  | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files'
+  | 'archives:read' | 'archives:create' | 'archives:update' | 'archives:delete' | 'archives:reprint'
+  | 'queue:read' | 'queue:create' | 'queue:update' | 'queue:delete' | 'queue:reorder'
+  | 'library:read' | 'library:upload' | 'library:update' | 'library:delete'
+  | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
+  | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
+  | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
+  | 'camera:view'
+  | 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
+  | 'kprofiles:read' | 'kprofiles:create' | 'kprofiles:update' | 'kprofiles:delete'
+  | 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete'
+  | 'notification_templates:read' | 'notification_templates:update'
+  | 'external_links:read' | 'external_links:create' | 'external_links:update' | 'external_links:delete'
+  | 'discovery:scan'
+  | 'firmware:read' | 'firmware:update'
+  | 'ams_history:read'
+  | 'stats:read'
+  | 'system:read'
+  | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
+  | 'github:backup' | 'github:restore'
+  | 'cloud:auth'
+  | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
+  | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
+  | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
+  | 'websocket:connect';
+
+// Group types
+export interface GroupBrief {
+  id: number;
+  name: string;
+}
+
+export interface Group {
+  id: number;
+  name: string;
+  description: string | null;
+  permissions: Permission[];
+  is_system: boolean;
+  user_count: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface GroupDetail extends Group {
+  users: Array<{ id: number; username: string; is_active: boolean }>;
+}
+
+export interface GroupCreate {
+  name: string;
+  description?: string;
+  permissions: Permission[];
+}
+
+export interface GroupUpdate {
+  name?: string;
+  description?: string;
+  permissions?: Permission[];
+}
+
+export interface PermissionInfo {
+  value: Permission;
+  label: string;
+}
+
+export interface PermissionCategory {
+  name: string;
+  permissions: PermissionInfo[];
+}
+
+export interface PermissionsListResponse {
+  categories: PermissionCategory[];
+  all_permissions: Permission[];
+}
+
 // Auth types
 // Auth types
 export interface LoginRequest {
 export interface LoginRequest {
   username: string;
   username: string;
@@ -1673,8 +1810,11 @@ export interface LoginResponse {
 export interface UserResponse {
 export interface UserResponse {
   id: number;
   id: number;
   username: string;
   username: string;
-  role: string;
+  role: string;  // Deprecated, kept for backward compatibility
   is_active: boolean;
   is_active: boolean;
+  is_admin: boolean;  // Computed from role and group membership
+  groups: GroupBrief[];
+  permissions: Permission[];  // All permissions from groups
   created_at: string;
   created_at: string;
 }
 }
 
 
@@ -1682,6 +1822,7 @@ export interface UserCreate {
   username: string;
   username: string;
   password: string;
   password: string;
   role: string;
   role: string;
+  group_ids?: number[];
 }
 }
 
 
 export interface UserUpdate {
 export interface UserUpdate {
@@ -1689,6 +1830,7 @@ export interface UserUpdate {
   password?: string;
   password?: string;
   role?: string;
   role?: string;
   is_active?: boolean;
   is_active?: boolean;
+  group_ids?: number[];
 }
 }
 
 
 export interface SetupRequest {
 export interface SetupRequest {
@@ -1731,7 +1873,7 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
 
 
-  // Users (admin only)
+  // Users
   getUsers: () => request<UserResponse[]>('/users/'),
   getUsers: () => request<UserResponse[]>('/users/'),
   getUser: (id: number) => request<UserResponse>(`/users/${id}`),
   getUser: (id: number) => request<UserResponse>(`/users/${id}`),
   createUser: (data: UserCreate) =>
   createUser: (data: UserCreate) =>
@@ -1748,6 +1890,38 @@ export const api = {
     request<void>(`/users/${id}`, {
     request<void>(`/users/${id}`, {
       method: 'DELETE',
       method: 'DELETE',
     }),
     }),
+  changePassword: (currentPassword: string, newPassword: string) =>
+    request<{ message: string }>('/users/me/change-password', {
+      method: 'POST',
+      body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
+    }),
+
+  // Groups
+  getPermissions: () => request<PermissionsListResponse>('/groups/permissions'),
+  getGroups: () => request<Group[]>('/groups/'),
+  getGroup: (id: number) => request<GroupDetail>(`/groups/${id}`),
+  createGroup: (data: GroupCreate) =>
+    request<Group>('/groups/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateGroup: (id: number, data: GroupUpdate) =>
+    request<Group>(`/groups/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteGroup: (id: number) =>
+    request<void>(`/groups/${id}`, {
+      method: 'DELETE',
+    }),
+  addUserToGroup: (groupId: number, userId: number) =>
+    request<void>(`/groups/${groupId}/users/${userId}`, {
+      method: 'POST',
+    }),
+  removeUserFromGroup: (groupId: number, userId: number) =>
+    request<void>(`/groups/${groupId}/users/${userId}`, {
+      method: 'DELETE',
+    }),
 
 
   // Printers
   // Printers
   getPrinters: () => request<Printer[]>('/printers/'),
   getPrinters: () => request<Printer[]>('/printers/'),
@@ -1931,6 +2105,17 @@ export const api = {
   deleteArchive: (id: number) =>
   deleteArchive: (id: number) =>
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
     request<void>(`/archives/${id}`, { method: 'DELETE' }),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
   getArchiveStats: () => request<ArchiveStats>('/archives/stats'),
+  // Tag management
+  getTags: () => request<TagInfo[]>('/archives/tags'),
+  renameTag: (oldName: string, newName: string) =>
+    request<{ affected: number }>(`/archives/tags/${encodeURIComponent(oldName)}`, {
+      method: 'PUT',
+      body: JSON.stringify({ new_name: newName }),
+    }),
+  deleteTag: (name: string) =>
+    request<{ affected: number }>(`/archives/tags/${encodeURIComponent(name)}`, {
+      method: 'DELETE',
+    }),
   recalculateCosts: () =>
   recalculateCosts: () =>
     request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
     request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
   getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
   getFailureAnalysis: (options?: { days?: number; printerId?: number; projectId?: number }) => {
@@ -2424,6 +2609,7 @@ export const api = {
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
   getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
   getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
   getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
+  getScriptPlugsByPrinter: (printerId: number) => request<SmartPlug[]>(`/smart-plugs/by-printer/${printerId}/scripts`),
   createSmartPlug: (data: SmartPlugCreate) =>
   createSmartPlug: (data: SmartPlugCreate) =>
     request<SmartPlug>('/smart-plugs/', {
     request<SmartPlug>('/smart-plugs/', {
       method: 'POST',
       method: 'POST',
@@ -3029,11 +3215,17 @@ export const api = {
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
     return request<LibraryFileListItem[]>(`/library/files?${params}`);
   },
   },
   getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
   getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
-  uploadLibraryFile: async (file: File, folderId?: number | null): Promise<LibraryFileUploadResponse> => {
+  uploadLibraryFile: async (
+    file: File,
+    folderId?: number | null,
+    generateStlThumbnails: boolean = true
+  ): Promise<LibraryFileUploadResponse> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
-    const params = folderId ? `?folder_id=${folderId}` : '';
-    const response = await fetch(`${API_BASE}/library/files${params}`, {
+    const params = new URLSearchParams();
+    if (folderId) params.set('folder_id', String(folderId));
+    params.set('generate_stl_thumbnails', String(generateStlThumbnails));
+    const response = await fetch(`${API_BASE}/library/files?${params}`, {
       method: 'POST',
       method: 'POST',
       body: formData,
       body: formData,
     });
     });
@@ -3047,7 +3239,8 @@ export const api = {
     file: File,
     file: File,
     folderId?: number | null,
     folderId?: number | null,
     preserveStructure: boolean = true,
     preserveStructure: boolean = true,
-    createFolderFromZip: boolean = false
+    createFolderFromZip: boolean = false,
+    generateStlThumbnails: boolean = true
   ): Promise<ZipExtractResponse> => {
   ): Promise<ZipExtractResponse> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
@@ -3055,6 +3248,7 @@ export const api = {
     if (folderId) params.set('folder_id', String(folderId));
     if (folderId) params.set('folder_id', String(folderId));
     params.set('preserve_structure', String(preserveStructure));
     params.set('preserve_structure', String(preserveStructure));
     params.set('create_folder_from_zip', String(createFolderFromZip));
     params.set('create_folder_from_zip', String(createFolderFromZip));
+    params.set('generate_stl_thumbnails', String(generateStlThumbnails));
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
     const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
       method: 'POST',
       method: 'POST',
       body: formData,
       body: formData,
@@ -3088,6 +3282,15 @@ export const api = {
       body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }),
       body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }),
     }),
     }),
   getLibraryStats: () => request<LibraryStats>('/library/stats'),
   getLibraryStats: () => request<LibraryStats>('/library/stats'),
+  batchGenerateStlThumbnails: (options: {
+    file_ids?: number[];
+    folder_id?: number;
+    all_missing?: boolean;
+  }) =>
+    request<BatchThumbnailResponse>('/library/generate-stl-thumbnails', {
+      method: 'POST',
+      body: JSON.stringify(options),
+    }),
   addLibraryFilesToQueue: (fileIds: number[]) =>
   addLibraryFilesToQueue: (fileIds: number[]) =>
     request<AddToQueueResponse>('/library/files/add-to-queue', {
     request<AddToQueueResponse>('/library/files/add-to-queue', {
       method: 'POST',
       method: 'POST',
@@ -3412,6 +3615,21 @@ export interface ZipExtractResponse {
   errors: ZipExtractError[];
   errors: ZipExtractError[];
 }
 }
 
 
+// STL Thumbnail Generation types
+export interface BatchThumbnailResult {
+  file_id: number;
+  filename: string;
+  success: boolean;
+  error?: string | null;
+}
+
+export interface BatchThumbnailResponse {
+  processed: number;
+  succeeded: number;
+  failed: number;
+  results: BatchThumbnailResult[];
+}
+
 // Library Queue types
 // Library Queue types
 export interface AddToQueueResult {
 export interface AddToQueueResult {
   file_id: number;
   file_id: number;

+ 275 - 66
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home } from 'lucide-react';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -15,7 +15,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const isEditing = !!plug;
   const isEditing = !!plug;
 
 
   // Plug type selection
   // Plug type selection
-  const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant'>(plug?.plug_type || 'tasmota');
+  const [plugType, setPlugType] = useState<'tasmota' | 'homeassistant' | 'mqtt'>(plug?.plug_type || 'tasmota');
 
 
   const [name, setName] = useState(plug?.name || '');
   const [name, setName] = useState(plug?.name || '');
   // Tasmota fields
   // Tasmota fields
@@ -24,6 +24,22 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [password, setPassword] = useState(plug?.password || '');
   const [password, setPassword] = useState(plug?.password || '');
   // Home Assistant fields
   // Home Assistant fields
   const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
   const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
+  // MQTT fields - Power
+  const [mqttPowerTopic, setMqttPowerTopic] = useState(plug?.mqtt_power_topic || plug?.mqtt_topic || '');
+  const [mqttPowerPath, setMqttPowerPath] = useState(plug?.mqtt_power_path || '');
+  const [mqttPowerMultiplier, setMqttPowerMultiplier] = useState<string>(
+    (plug?.mqtt_power_multiplier ?? plug?.mqtt_multiplier ?? 1).toString()
+  );
+  // MQTT fields - Energy
+  const [mqttEnergyTopic, setMqttEnergyTopic] = useState(plug?.mqtt_energy_topic || '');
+  const [mqttEnergyPath, setMqttEnergyPath] = useState(plug?.mqtt_energy_path || '');
+  const [mqttEnergyMultiplier, setMqttEnergyMultiplier] = useState<string>(
+    (plug?.mqtt_energy_multiplier ?? 1).toString()
+  );
+  // MQTT fields - State
+  const [mqttStateTopic, setMqttStateTopic] = useState(plug?.mqtt_state_topic || '');
+  const [mqttStatePath, setMqttStatePath] = useState(plug?.mqtt_state_path || '');
+  const [mqttStateOnValue, setMqttStateOnValue] = useState(plug?.mqtt_state_on_value || '');
   // HA energy sensor entities (optional)
   // HA energy sensor entities (optional)
   const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
   const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
   const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
   const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
@@ -279,6 +295,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       return;
       return;
     }
     }
 
 
+    if (plugType === 'mqtt') {
+      // Check that at least one topic is configured (path is optional)
+      const hasPower = mqttPowerTopic.trim();
+      const hasEnergy = mqttEnergyTopic.trim();
+      const hasState = mqttStateTopic.trim();
+
+      if (!hasPower && !hasEnergy && !hasState) {
+        setError('At least one MQTT topic must be configured for power, energy, or state monitoring');
+        return;
+      }
+    }
+
     const data = {
     const data = {
       name: name.trim(),
       name: name.trim(),
       plug_type: plugType,
       plug_type: plugType,
@@ -288,6 +316,18 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
       ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
       ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
       ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
       ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,
       ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,
+      // MQTT power fields
+      mqtt_power_topic: plugType === 'mqtt' ? (mqttPowerTopic.trim() || null) : null,
+      mqtt_power_path: plugType === 'mqtt' ? (mqttPowerPath.trim() || null) : null,
+      mqtt_power_multiplier: plugType === 'mqtt' ? (parseFloat(mqttPowerMultiplier) || 1) : 1,
+      // MQTT energy fields
+      mqtt_energy_topic: plugType === 'mqtt' ? (mqttEnergyTopic.trim() || null) : null,
+      mqtt_energy_path: plugType === 'mqtt' ? (mqttEnergyPath.trim() || null) : null,
+      mqtt_energy_multiplier: plugType === 'mqtt' ? (parseFloat(mqttEnergyMultiplier) || 1) : 1,
+      // MQTT state fields
+      mqtt_state_topic: plugType === 'mqtt' ? (mqttStateTopic.trim() || null) : null,
+      mqtt_state_path: plugType === 'mqtt' ? (mqttStatePath.trim() || null) : null,
+      mqtt_state_on_value: plugType === 'mqtt' ? (mqttStateOnValue.trim() || null) : null,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
       printer_id: printerId,
@@ -352,7 +392,7 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                   setTestResult(null);
                   setTestResult(null);
                   setError(null);
                   setError(null);
                 }}
                 }}
-                className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
+                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
                   plugType === 'tasmota'
                   plugType === 'tasmota'
                     ? 'bg-bambu-green text-white'
                     ? 'bg-bambu-green text-white'
                     : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
                     : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
@@ -368,14 +408,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                   setTestResult(null);
                   setTestResult(null);
                   setError(null);
                   setError(null);
                 }}
                 }}
-                className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
+                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
                   plugType === 'homeassistant'
                   plugType === 'homeassistant'
                     ? 'bg-bambu-green text-white'
                     ? 'bg-bambu-green text-white'
                     : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
                     : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
                 }`}
                 }`}
               >
               >
                 <Home className="w-4 h-4" />
                 <Home className="w-4 h-4" />
-                Home Assistant
+                HA
+              </button>
+              <button
+                type="button"
+                onClick={() => {
+                  setPlugType('mqtt');
+                  setTestResult(null);
+                  setError(null);
+                }}
+                className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 rounded-lg font-medium transition-colors ${
+                  plugType === 'mqtt'
+                    ? 'bg-bambu-green text-white'
+                    : 'bg-bambu-dark text-bambu-gray hover:text-white border border-bambu-dark-tertiary'
+                }`}
+              >
+                <Radio className="w-4 h-4" />
+                MQTT
               </button>
               </button>
             </div>
             </div>
           )}
           )}
@@ -857,6 +913,155 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
             </div>
           )}
           )}
 
 
+          {/* MQTT Configuration - only show when MQTT is selected */}
+          {plugType === 'mqtt' && (
+            <div className="space-y-3">
+              {/* MQTT broker not configured */}
+              {!settings?.mqtt_broker && (
+                <div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
+                  MQTT broker not configured. Set broker address in{' '}
+                  <span className="font-medium">Settings → Network → MQTT Publishing</span>
+                  {' '}(you don't need to enable publishing, just fill in the broker details).
+                </div>
+              )}
+
+              {/* MQTT broker configured - show fields */}
+              {settings?.mqtt_broker && (
+                <>
+                  <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm text-blue-300">
+                    <p className="font-medium mb-1">Monitor Only</p>
+                    <p className="text-xs opacity-80">
+                      MQTT plugs receive power/energy data via MQTT subscription. On/off control is not available - use your MQTT broker or home automation system.
+                    </p>
+                  </div>
+
+                  {/* Power Section */}
+                  <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+                    <p className="text-white font-medium text-sm">Power Monitoring</p>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Topic</label>
+                      <input
+                        type="text"
+                        value={mqttPowerTopic}
+                        onChange={(e) => setMqttPowerTopic(e.target.value)}
+                        placeholder="zigbee2mqtt/shelly-working-room"
+                        className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                    <div className="grid grid-cols-2 gap-3">
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
+                        <input
+                          type="text"
+                          value={mqttPowerPath}
+                          onChange={(e) => setMqttPowerPath(e.target.value)}
+                          placeholder="power_l1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
+                        <input
+                          type="text"
+                          value={mqttPowerMultiplier}
+                          onChange={(e) => setMqttPowerMultiplier(e.target.value)}
+                          placeholder="1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                    </div>
+                    <p className="text-xs text-bambu-gray">
+                      JSON path extracts value from JSON payload (e.g., "power_l1"). Leave empty if topic publishes raw numeric values.<br/>
+                      Use multiplier 0.001 for mW→W, 1000 for kW→W.
+                    </p>
+                  </div>
+
+                  {/* Energy Section */}
+                  <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+                    <p className="text-white font-medium text-sm">Energy Monitoring <span className="text-bambu-gray font-normal">(optional)</span></p>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Topic</label>
+                      <input
+                        type="text"
+                        value={mqttEnergyTopic}
+                        onChange={(e) => setMqttEnergyTopic(e.target.value)}
+                        placeholder="Same as power topic, or different"
+                        className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                    <div className="grid grid-cols-2 gap-3">
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
+                        <input
+                          type="text"
+                          value={mqttEnergyPath}
+                          onChange={(e) => setMqttEnergyPath(e.target.value)}
+                          placeholder="energy_l1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">Multiplier</label>
+                        <input
+                          type="text"
+                          value={mqttEnergyMultiplier}
+                          onChange={(e) => setMqttEnergyMultiplier(e.target.value)}
+                          placeholder="1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                    </div>
+                    <p className="text-xs text-bambu-gray">
+                      JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
+                      Use multiplier 0.001 for Wh→kWh, 1000 for MWh→kWh.
+                    </p>
+                  </div>
+
+                  {/* State Section */}
+                  <div className="space-y-3 p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+                    <p className="text-white font-medium text-sm">State Monitoring <span className="text-bambu-gray font-normal">(optional)</span></p>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Topic</label>
+                      <input
+                        type="text"
+                        value={mqttStateTopic}
+                        onChange={(e) => setMqttStateTopic(e.target.value)}
+                        placeholder="Same as power topic, or different"
+                        className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                    <div className="grid grid-cols-2 gap-3">
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">JSON Path</label>
+                        <input
+                          type="text"
+                          value={mqttStatePath}
+                          onChange={(e) => setMqttStatePath(e.target.value)}
+                          placeholder="state_l1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                      <div>
+                        <label className="block text-sm text-bambu-gray mb-1">ON Value</label>
+                        <input
+                          type="text"
+                          value={mqttStateOnValue}
+                          onChange={(e) => setMqttStateOnValue(e.target.value)}
+                          placeholder="ON, true, 1"
+                          className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                        />
+                      </div>
+                    </div>
+                    <p className="text-xs text-bambu-gray">
+                      JSON path extracts value from JSON payload. Leave empty for raw values.<br/>
+                      ON value: the exact string that means "ON". Leave empty for auto-detect (ON, true, 1).
+                    </p>
+                  </div>
+                </>
+              )}
+            </div>
+          )}
+
           {/* IP Address - only show for Tasmota */}
           {/* IP Address - only show for Tasmota */}
           {plugType === 'tasmota' && (
           {plugType === 'tasmota' && (
             <div>
             <div>
@@ -959,25 +1164,27 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </>
             </>
           )}
           )}
 
 
-          {/* Link to Printer */}
-          <div>
-            <label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
-            <select
-              value={printerId ?? ''}
-              onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
-              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-            >
-              <option value="">No printer (manual control only)</option>
-              {availablePrinters?.map((p) => (
-                <option key={p.id} value={p.id}>
-                  {p.name}
-                </option>
-              ))}
-            </select>
-            <p className="text-xs text-bambu-gray mt-1">
-              Linking enables automatic on/off when prints start/complete
-            </p>
-          </div>
+          {/* Link to Printer - not shown for MQTT plugs (monitor-only) */}
+          {plugType !== 'mqtt' && (
+            <div>
+              <label className="block text-sm text-bambu-gray mb-1">Link to Printer</label>
+              <select
+                value={printerId ?? ''}
+                onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+              >
+                <option value="">No printer (manual control only)</option>
+                {availablePrinters?.map((p) => (
+                  <option key={p.id} value={p.id}>
+                    {p.name}
+                  </option>
+                ))}
+              </select>
+              <p className="text-xs text-bambu-gray mt-1">
+                Linking enables automatic on/off when prints start/complete
+              </p>
+            </div>
+          )}
 
 
           {/* Power Alerts */}
           {/* Power Alerts */}
           <div className="border-t border-bambu-dark-tertiary pt-4">
           <div className="border-t border-bambu-dark-tertiary pt-4">
@@ -1031,51 +1238,53 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             )}
             )}
           </div>
           </div>
 
 
-          {/* Schedule */}
-          <div className="border-t border-bambu-dark-tertiary pt-4">
-            <div className="flex items-center justify-between mb-3">
-              <div className="flex items-center gap-2">
-                <Clock className="w-4 h-4 text-bambu-green" />
-                <span className="text-white font-medium">Daily Schedule</span>
+          {/* Schedule - not shown for MQTT plugs (monitor-only) */}
+          {plugType !== 'mqtt' && (
+            <div className="border-t border-bambu-dark-tertiary pt-4">
+              <div className="flex items-center justify-between mb-3">
+                <div className="flex items-center gap-2">
+                  <Clock className="w-4 h-4 text-bambu-green" />
+                  <span className="text-white font-medium">Daily Schedule</span>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={scheduleEnabled}
+                    onChange={(e) => setScheduleEnabled(e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
               </div>
               </div>
-              <label className="relative inline-flex items-center cursor-pointer">
-                <input
-                  type="checkbox"
-                  checked={scheduleEnabled}
-                  onChange={(e) => setScheduleEnabled(e.target.checked)}
-                  className="sr-only peer"
-                />
-                <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-              </label>
-            </div>
-            {scheduleEnabled && (
-              <div className="space-y-3">
-                <div className="grid grid-cols-2 gap-3">
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
-                    <input
-                      type="time"
-                      value={scheduleOnTime}
-                      onChange={(e) => setScheduleOnTime(e.target.value)}
-                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                    />
-                  </div>
-                  <div>
-                    <label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
-                    <input
-                      type="time"
-                      value={scheduleOffTime}
-                      onChange={(e) => setScheduleOffTime(e.target.value)}
-                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                    />
+              {scheduleEnabled && (
+                <div className="space-y-3">
+                  <div className="grid grid-cols-2 gap-3">
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Turn On at</label>
+                      <input
+                        type="time"
+                        value={scheduleOnTime}
+                        onChange={(e) => setScheduleOnTime(e.target.value)}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
+                    <div>
+                      <label className="block text-sm text-bambu-gray mb-1">Turn Off at</label>
+                      <input
+                        type="time"
+                        value={scheduleOffTime}
+                        onChange={(e) => setScheduleOffTime(e.target.value)}
+                        className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+                      />
+                    </div>
                   </div>
                   </div>
+                  <p className="text-xs text-bambu-gray">
+                    Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
+                  </p>
                 </div>
                 </div>
-                <p className="text-xs text-bambu-gray">
-                  Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
-                </p>
-              </div>
-            )}
-          </div>
+              )}
+            </div>
+          )}
 
 
           {/* Switchbar Visibility */}
           {/* Switchbar Visibility */}
           <div className="border-t border-bambu-dark-tertiary pt-4">
           <div className="border-t border-bambu-dark-tertiary pt-4">

+ 2 - 0
frontend/src/components/ContextMenu.tsx

@@ -9,6 +9,7 @@ export interface ContextMenuItem {
   disabled?: boolean;
   disabled?: boolean;
   divider?: boolean;
   divider?: boolean;
   submenu?: ContextMenuItem[];
   submenu?: ContextMenuItem[];
+  title?: string;
 }
 }
 
 
 interface ContextMenuProps {
 interface ContextMenuProps {
@@ -163,6 +164,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
                 }
                 }
               }}
               }}
               disabled={item.disabled}
               disabled={item.disabled}
+              title={item.title}
               className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
               className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
                 item.disabled
                 item.disabled
                   ? 'text-bambu-gray cursor-not-allowed'
                   ? 'text-bambu-gray cursor-not-allowed'

+ 33 - 14
frontend/src/components/EditArchiveModal.tsx

@@ -68,30 +68,49 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
     queryFn: () => api.getProjects(),
     queryFn: () => api.getProjects(),
   });
   });
 
 
-  // Get all archives to extract existing tags if not provided
-  const { data: archives } = useQuery({
-    queryKey: ['archives'],
-    queryFn: () => api.getArchives(undefined, 1000, 0),
+  // Fetch all tags using the dedicated API
+  const { data: tagsData } = useQuery({
+    queryKey: ['tags'],
+    queryFn: api.getTags,
     enabled: existingTags.length === 0,
     enabled: existingTags.length === 0,
   });
   });
 
 
-  // Extract unique tags from all archives
+  // Use existing tags prop if provided, otherwise use fetched tags
   const allTags = existingTags.length > 0
   const allTags = existingTags.length > 0
     ? existingTags
     ? existingTags
-    : [...new Set(
-        archives?.flatMap(a => a.tags?.split(',').map(t => t.trim()) || []).filter(Boolean) || []
-      )].sort();
+    : (tagsData?.map(t => t.name) || []);
 
 
   // Get current tags as array
   // Get current tags as array
   const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
   const currentTags = tags.split(',').map(t => t.trim()).filter(Boolean);
 
 
-  // Filter suggestions based on what's not already added
-  const tagSuggestions = allTags.filter(t => !currentTags.includes(t));
+  // Get the text being typed after the last comma (for autocomplete filtering)
+  const currentInput = tags.includes(',')
+    ? tags.substring(tags.lastIndexOf(',') + 1).trim().toLowerCase()
+    : tags.trim().toLowerCase();
 
 
-  // Add a tag
+  // Filter suggestions: not already added AND matches current input (if any)
+  const tagSuggestions = allTags.filter(t =>
+    !currentTags.includes(t) &&
+    (currentInput === '' || t.toLowerCase().includes(currentInput))
+  );
+
+  // Add a tag (replaces any partial input with the selected tag)
   const addTag = (tag: string) => {
   const addTag = (tag: string) => {
-    if (!currentTags.includes(tag)) {
-      const newTags = [...currentTags, tag].join(', ');
+    // If there's partial input being typed, replace it with the selected tag
+    // Otherwise, just append the tag
+    let baseTags: string[];
+    if (currentInput && !allTags.includes(currentInput)) {
+      // User is typing a partial tag - replace it with the selected one
+      baseTags = tags.includes(',')
+        ? tags.substring(0, tags.lastIndexOf(',')).split(',').map(t => t.trim()).filter(Boolean)
+        : [];
+    } else {
+      // No partial input or input is already a complete tag - append
+      baseTags = currentTags;
+    }
+
+    if (!baseTags.includes(tag)) {
+      const newTags = [...baseTags, tag].join(', ');
       setTags(newTags);
       setTags(newTags);
     }
     }
     // Clear any pending blur timeout to prevent hiding suggestions
     // Clear any pending blur timeout to prevent hiding suggestions
@@ -342,7 +361,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               {showTagSuggestions && tagSuggestions.length > 0 && (
               {showTagSuggestions && tagSuggestions.length > 0 && (
                 <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
                 <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
                   <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
                   <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
-                    Existing tags (click to add)
+                    {currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
                   </div>
                   </div>
                   <div className="p-2 flex flex-wrap gap-1.5">
                   <div className="p-2 flex flex-wrap gap-1.5">
                     {tagSuggestions.map((tag) => (
                     {tagSuggestions.map((tag) => (

+ 27 - 12
frontend/src/components/KProfilesView.tsx

@@ -19,10 +19,11 @@ import {
   StickyNote,
   StickyNote,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
+import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 
 interface KProfileCardProps {
 interface KProfileCardProps {
   profile: KProfile;
   profile: KProfile;
@@ -154,6 +155,7 @@ interface KProfileModalProps {
   onClose: () => void;
   onClose: () => void;
   onSave: () => void;
   onSave: () => void;
   onSaveNote?: (settingId: string, note: string) => void;  // Callback to save note
   onSaveNote?: (settingId: string, note: string) => void;  // Callback to save note
+  hasPermission: (permission: Permission) => boolean;
 }
 }
 
 
 function KProfileModal({
 function KProfileModal({
@@ -167,6 +169,7 @@ function KProfileModal({
   onClose,
   onClose,
   onSave,
   onSave,
   onSaveNote,
   onSaveNote,
+  hasPermission,
 }: KProfileModalProps) {
 }: KProfileModalProps) {
   const { showToast } = useToast();
   const { showToast } = useToast();
 
 
@@ -590,7 +593,8 @@ function KProfileModal({
                   type="button"
                   type="button"
                   variant="secondary"
                   variant="secondary"
                   onClick={() => setShowDeleteConfirm(true)}
                   onClick={() => setShowDeleteConfirm(true)}
-                  disabled={deleteMutation.isPending || isSyncing}
+                  disabled={deleteMutation.isPending || isSyncing || !hasPermission('kprofiles:delete')}
+                  title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete K-profiles' : undefined}
                   className="text-red-500 hover:bg-red-500/10"
                   className="text-red-500 hover:bg-red-500/10"
                 >
                 >
                   {deleteMutation.isPending ? (
                   {deleteMutation.isPending ? (
@@ -611,7 +615,8 @@ function KProfileModal({
               </Button>
               </Button>
               <Button
               <Button
                 type="submit"
                 type="submit"
-                disabled={saveMutation.isPending || isSyncing}
+                disabled={saveMutation.isPending || isSyncing || !hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create')}
+                title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? `You do not have permission to ${profile ? 'update' : 'create'} K-profiles` : undefined}
                 className="flex-1"
                 className="flex-1"
               >
               >
                 {saveMutation.isPending ? (
                 {saveMutation.isPending ? (
@@ -687,6 +692,7 @@ const STORAGE_KEYS = {
 
 
 export function KProfilesView() {
 export function KProfilesView() {
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
   // Load nozzle diameter from localStorage
   // Load nozzle diameter from localStorage
   const [nozzleDiameter, setNozzleDiameter] = useState(() => {
   const [nozzleDiameter, setNozzleDiameter] = useState(() => {
@@ -1139,12 +1145,17 @@ export function KProfilesView() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={() => refetchProfiles()}
             onClick={() => refetchProfiles()}
-            disabled={isFetching}
+            disabled={isFetching || !hasPermission('kprofiles:read')}
+            title={!hasPermission('kprofiles:read') ? 'You do not have permission to refresh profiles' : undefined}
           >
           >
             <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
             <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
             Refresh
             Refresh
           </Button>
           </Button>
-          <Button onClick={() => setShowAddModal(true)}>
+          <Button
+            onClick={() => setShowAddModal(true)}
+            disabled={!hasPermission('kprofiles:create')}
+            title={!hasPermission('kprofiles:create') ? 'You do not have permission to add profiles' : undefined}
+          >
             <Plus className="w-4 h-4" />
             <Plus className="w-4 h-4" />
             Add Profile
             Add Profile
           </Button>
           </Button>
@@ -1205,8 +1216,8 @@ export function KProfilesView() {
         <Button
         <Button
           variant="secondary"
           variant="secondary"
           onClick={handleExport}
           onClick={handleExport}
-          disabled={!kprofiles?.profiles?.length}
-          title="Export profiles to JSON"
+          disabled={!kprofiles?.profiles?.length || !hasPermission('kprofiles:read')}
+          title={!hasPermission('kprofiles:read') ? 'You do not have permission to export profiles' : 'Export profiles to JSON'}
         >
         >
           <Download className="w-4 h-4" />
           <Download className="w-4 h-4" />
           Export
           Export
@@ -1214,7 +1225,8 @@ export function KProfilesView() {
         <Button
         <Button
           variant="secondary"
           variant="secondary"
           onClick={handleImport}
           onClick={handleImport}
-          title="Import profiles from JSON"
+          disabled={!hasPermission('kprofiles:create')}
+          title={!hasPermission('kprofiles:create') ? 'You do not have permission to import profiles' : 'Import profiles from JSON'}
         >
         >
           <Upload className="w-4 h-4" />
           <Upload className="w-4 h-4" />
           Import
           Import
@@ -1233,9 +1245,9 @@ export function KProfilesView() {
             <Button
             <Button
               variant="secondary"
               variant="secondary"
               onClick={handleBulkDelete}
               onClick={handleBulkDelete}
-              disabled={selectedProfiles.size === 0}
+              disabled={selectedProfiles.size === 0 || !hasPermission('kprofiles:delete')}
               className="text-red-500 hover:bg-red-500/10"
               className="text-red-500 hover:bg-red-500/10"
-              title={`Delete ${selectedProfiles.size} selected profiles`}
+              title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : `Delete ${selectedProfiles.size} selected profiles`}
             >
             >
               <Trash2 className="w-4 h-4" />
               <Trash2 className="w-4 h-4" />
               Delete ({selectedProfiles.size})
               Delete ({selectedProfiles.size})
@@ -1255,8 +1267,8 @@ export function KProfilesView() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={() => setSelectionMode(true)}
             onClick={() => setSelectionMode(true)}
-            disabled={!filteredProfiles.length}
-            title="Enter selection mode for bulk delete"
+            disabled={!filteredProfiles.length || !hasPermission('kprofiles:delete')}
+            title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : 'Enter selection mode for bulk delete'}
           >
           >
             <CheckSquare className="w-4 h-4" />
             <CheckSquare className="w-4 h-4" />
             Select
             Select
@@ -1384,6 +1396,7 @@ export function KProfilesView() {
             initialNote={note}
             initialNote={note}
             initialNoteKey={key}
             initialNoteKey={key}
             onSaveNote={handleSaveNote}
             onSaveNote={handleSaveNote}
+            hasPermission={hasPermission}
             onClose={() => {
             onClose={() => {
               console.log('[KProfiles] Edit modal onClose - refetching profiles...');
               console.log('[KProfiles] Edit modal onClose - refetching profiles...');
               setEditingProfile(null);
               setEditingProfile(null);
@@ -1405,6 +1418,7 @@ export function KProfilesView() {
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
           isDualNozzle={isDualNozzle}
           isDualNozzle={isDualNozzle}
           onSaveNote={handleSaveNote}
           onSaveNote={handleSaveNote}
+          hasPermission={hasPermission}
           onClose={() => {
           onClose={() => {
             setShowAddModal(false);
             setShowAddModal(false);
             refetchProfiles();  // Refetch after close
             refetchProfiles();  // Refetch after close
@@ -1424,6 +1438,7 @@ export function KProfilesView() {
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
           isDualNozzle={isDualNozzle}
           isDualNozzle={isDualNozzle}
           onSaveNote={handleSaveNote}
           onSaveNote={handleSaveNote}
+          hasPermission={hasPermission}
           // Pass profile data but without slot_id to create a new profile
           // Pass profile data but without slot_id to create a new profile
           profile={{
           profile={{
             ...copyingProfile,
             ...copyingProfile,

+ 216 - 38
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -10,6 +10,9 @@ import { api, supportApi, pendingUploadsApi } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { getIconByName } from './IconPicker';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { Card, CardHeader, CardContent } from './Card';
+import { Button } from './Button';
 
 
 interface NavItem {
 interface NavItem {
   id: string;
   id: string;
@@ -69,7 +72,11 @@ export function Layout() {
   const { mode, toggleMode } = useTheme();
   const { mode, toggleMode } = useTheme();
   const { t } = useTranslation();
   const { t } = useTranslation();
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
-  const { user, authEnabled, logout } = useAuth();
+  const { user, authEnabled, logout, hasPermission } = useAuth();
+  const { showToast } = useToast();
+  const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
+  const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
+  const [changePasswordLoading, setChangePasswordLoading] = useState(false);
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
     const stored = localStorage.getItem('sidebarExpanded');
     const stored = localStorage.getItem('sidebarExpanded');
     return stored !== 'false';
     return stored !== 'false';
@@ -564,17 +571,26 @@ export function Layout() {
                     )}
                     )}
                   </div>
                   </div>
                 )}
                 )}
-                <NavLink
-                  to="/system"
-                  className={({ isActive }) =>
-                    `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
-                      isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
-                    }`
-                  }
-                  title={t('nav.system')}
-                >
-                  <Info className="w-5 h-5" />
-                </NavLink>
+                {hasPermission('system:read') ? (
+                  <NavLink
+                    to="/system"
+                    className={({ isActive }) =>
+                      `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+                        isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
+                      }`
+                    }
+                    title={t('nav.system')}
+                  >
+                    <Info className="w-5 h-5" />
+                  </NavLink>
+                ) : (
+                  <span
+                    className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
+                    title="You do not have permission to view system information"
+                  >
+                    <Info className="w-5 h-5" />
+                  </span>
+                )}
                 <a
                 <a
                   href="https://github.com/maziggy/bambuddy"
                   href="https://github.com/maziggy/bambuddy"
                   target="_blank"
                   target="_blank"
@@ -599,13 +615,22 @@ export function Layout() {
                   {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                   {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 </button>
                 </button>
                 {authEnabled && user && (
                 {authEnabled && user && (
-                  <button
-                    onClick={logout}
-                    className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                    title={t('nav.logout', { defaultValue: 'Logout' })}
-                  >
-                    <LogOut className="w-5 h-5" />
-                  </button>
+                  <>
+                    <button
+                      onClick={() => setShowChangePasswordModal(true)}
+                      className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                      title="Change Password"
+                    >
+                      <Key className="w-5 h-5" />
+                    </button>
+                    <button
+                      onClick={logout}
+                      className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                      title={t('nav.logout', { defaultValue: 'Logout' })}
+                    >
+                      <LogOut className="w-5 h-5" />
+                    </button>
+                  </>
                 )}
                 )}
               </div>
               </div>
               {/* Bottom row: version */}
               {/* Bottom row: version */}
@@ -650,17 +675,26 @@ export function Layout() {
                   )}
                   )}
                 </div>
                 </div>
               )}
               )}
-              <NavLink
-                to="/system"
-                className={({ isActive }) =>
-                  `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
-                    isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
-                  }`
-                }
-                title={t('nav.system')}
-              >
-                <Info className="w-5 h-5" />
-              </NavLink>
+              {hasPermission('system:read') ? (
+                <NavLink
+                  to="/system"
+                  className={({ isActive }) =>
+                    `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+                      isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
+                    }`
+                  }
+                  title={t('nav.system')}
+                >
+                  <Info className="w-5 h-5" />
+                </NavLink>
+              ) : (
+                <span
+                  className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
+                  title="You do not have permission to view system information"
+                >
+                  <Info className="w-5 h-5" />
+                </span>
+              )}
               <a
               <a
                 href="https://github.com/maziggy/bambuddy"
                 href="https://github.com/maziggy/bambuddy"
                 target="_blank"
                 target="_blank"
@@ -685,13 +719,22 @@ export function Layout() {
                 {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
               </button>
               </button>
               {authEnabled && user && (
               {authEnabled && user && (
-                <button
-                  onClick={logout}
-                  className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title={t('nav.logout', { defaultValue: 'Logout' })}
-                >
-                  <LogOut className="w-5 h-5" />
-                </button>
+                <>
+                  <button
+                    onClick={() => setShowChangePasswordModal(true)}
+                    className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                    title="Change Password"
+                  >
+                    <Key className="w-5 h-5" />
+                  </button>
+                  <button
+                    onClick={logout}
+                    className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                    title={t('nav.logout', { defaultValue: 'Logout' })}
+                  >
+                    <LogOut className="w-5 h-5" />
+                  </button>
+                </>
               )}
               )}
             </div>
             </div>
           )}
           )}
@@ -800,6 +843,141 @@ export function Layout() {
           </div>
           </div>
         </div>
         </div>
       )}
       )}
+
+      {/* Change Password Modal */}
+      {showChangePasswordModal && (
+        <div
+          className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+          onClick={() => {
+            setShowChangePasswordModal(false);
+            setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
+          }}
+        >
+          <Card
+            className="w-full max-w-md"
+            onClick={(e: React.MouseEvent) => e.stopPropagation()}
+          >
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Key className="w-5 h-5 text-bambu-green" />
+                  <h2 className="text-lg font-semibold text-white">Change Password</h2>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => {
+                    setShowChangePasswordModal(false);
+                    setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
+                  }}
+                >
+                  <X className="w-5 h-5" />
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div className="space-y-4">
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Current Password
+                  </label>
+                  <input
+                    type="password"
+                    value={changePasswordData.currentPassword}
+                    onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
+                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                    placeholder="Enter current password"
+                    autoComplete="current-password"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    New Password
+                  </label>
+                  <input
+                    type="password"
+                    value={changePasswordData.newPassword}
+                    onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
+                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                    placeholder="Enter new password (min 6 characters)"
+                    autoComplete="new-password"
+                    minLength={6}
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Confirm New Password
+                  </label>
+                  <input
+                    type="password"
+                    value={changePasswordData.confirmPassword}
+                    onChange={(e) => setChangePasswordData({ ...changePasswordData, confirmPassword: e.target.value })}
+                    className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
+                      changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword
+                        ? 'border-red-500'
+                        : 'border-bambu-dark-tertiary'
+                    }`}
+                    placeholder="Confirm new password"
+                    autoComplete="new-password"
+                    minLength={6}
+                  />
+                  {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
+                    <p className="text-red-400 text-xs mt-1">Passwords do not match</p>
+                  )}
+                </div>
+              </div>
+              <div className="mt-6 flex justify-end gap-3">
+                <Button
+                  variant="secondary"
+                  onClick={() => {
+                    setShowChangePasswordModal(false);
+                    setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
+                  }}
+                >
+                  Cancel
+                </Button>
+                <Button
+                  onClick={async () => {
+                    if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
+                      showToast('Passwords do not match', 'error');
+                      return;
+                    }
+                    if (changePasswordData.newPassword.length < 6) {
+                      showToast('Password must be at least 6 characters', 'error');
+                      return;
+                    }
+                    setChangePasswordLoading(true);
+                    try {
+                      await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
+                      showToast('Password changed successfully', 'success');
+                      setShowChangePasswordModal(false);
+                      setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
+                    } catch (error: unknown) {
+                      const message = error instanceof Error ? error.message : 'Failed to change password';
+                      showToast(message, 'error');
+                    } finally {
+                      setChangePasswordLoading(false);
+                    }
+                  }}
+                  disabled={changePasswordLoading || !changePasswordData.currentPassword || !changePasswordData.newPassword || changePasswordData.newPassword !== changePasswordData.confirmPassword || changePasswordData.newPassword.length < 6}
+                >
+                  {changePasswordLoading ? (
+                    <>
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      Changing...
+                    </>
+                  ) : (
+                    <>
+                      <Key className="w-4 h-4" />
+                      Change Password
+                    </>
+                  )}
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 128 - 73
frontend/src/components/SmartPlugCard.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home } from 'lucide-react';
+import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Radio, Eye } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import type { SmartPlug, SmartPlugUpdate } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
@@ -92,7 +92,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
   });
   });
 
 
   const isOn = status?.state === 'ON';
   const isOn = status?.state === 'ON';
-  const isReachable = status?.reachable ?? false;
+  // For MQTT plugs, consider reachable if we have power data (even if backend says not reachable)
+  const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
+  const isReachable = (status?.reachable ?? false) || hasMqttData;
   const isPending = controlMutation.isPending;
   const isPending = controlMutation.isPending;
 
 
   // Generate admin URL with auto-login credentials (Tasmota only)
   // Generate admin URL with auto-login credentials (Tasmota only)
@@ -113,27 +115,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       <Card className="relative">
       <Card className="relative">
         <CardContent className="p-4">
         <CardContent className="p-4">
           {/* Header Row */}
           {/* Header Row */}
-          <div className="flex items-start justify-between mb-3">
-            <div className="flex items-center gap-3">
-              <div className={`p-2 rounded-lg ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-                {plug.plug_type === 'homeassistant' ? (
+          <div className="flex items-start justify-between gap-2 mb-3">
+            <div className="flex items-center gap-3 min-w-0 flex-1">
+              <div className={`p-2 rounded-lg flex-shrink-0 ${
+                plug.plug_type === 'mqtt'
+                  ? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
+                  : (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
+              }`}>
+                {plug.plug_type === 'mqtt' ? (
+                  <Radio className={`w-5 h-5 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
+                ) : plug.plug_type === 'homeassistant' ? (
                   <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                   <Home className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                 ) : (
                 ) : (
                   <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                   <Plug className={`w-5 h-5 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
                 )}
                 )}
               </div>
               </div>
-              <div>
-                <h3 className="font-medium text-white">{plug.name}</h3>
-                <p className="text-sm text-bambu-gray">
-                  {plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
+              <div className="min-w-0">
+                <h3 className="font-medium text-white truncate">{plug.name}</h3>
+                <p
+                  className="text-sm text-bambu-gray truncate"
+                  title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.ip_address ?? undefined}
+                >
+                  {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}
                 </p>
                 </p>
               </div>
               </div>
             </div>
             </div>
 
 
             {/* Status indicator */}
             {/* Status indicator */}
-            <div className="flex flex-col items-end gap-1">
+            <div className="flex flex-col items-end gap-1 flex-shrink-0">
               {statusLoading ? (
               {statusLoading ? (
                 <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
                 <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
+              ) : plug.plug_type === 'mqtt' ? (
+                /* MQTT plugs - show badge and checkmark when receiving data */
+                <div className="flex items-center gap-1.5 text-sm whitespace-nowrap">
+                  <span className="px-1.5 py-0.5 bg-teal-500/20 text-teal-400 text-[10px] font-medium rounded flex-shrink-0">MQTT</span>
+                  {isReachable && <span className="text-status-ok">✓</span>}
+                </div>
+              ) : plug.plug_type === 'homeassistant' ? (
+                <div className="flex items-center gap-1 text-sm">
+                  <span className="px-1 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] font-medium rounded">HA</span>
+                  <span className={isReachable ? (isOn ? 'text-status-ok' : 'text-bambu-gray') : 'text-status-error'}>
+                    {isReachable ? (status?.state || '?') : 'Offline'}
+                  </span>
+                </div>
               ) : isReachable ? (
               ) : isReachable ? (
                 <div className="flex items-center gap-1 text-sm">
                 <div className="flex items-center gap-1 text-sm">
                   <Wifi className="w-4 h-4 text-status-ok" />
                   <Wifi className="w-4 h-4 text-status-ok" />
@@ -170,8 +194,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           )}
           )}
 
 
           {/* Feature Badges */}
           {/* Feature Badges */}
-          {(plug.power_alert_enabled || plug.schedule_enabled) && (
+          {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
             <div className="flex flex-wrap gap-1.5 mb-3">
             <div className="flex flex-wrap gap-1.5 mb-3">
+              {plug.plug_type === 'mqtt' && (
+                <span className="flex items-center gap-1 px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded-full">
+                  <Eye className="w-3 h-3" />
+                  Monitor Only
+                </span>
+              )}
               {plug.power_alert_enabled && (
               {plug.power_alert_enabled && (
                 <span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
                 <span className="flex items-center gap-1 px-2 py-0.5 bg-yellow-500/20 text-yellow-400 text-xs rounded-full">
                   <Bell className="w-3 h-3" />
                   <Bell className="w-3 h-3" />
@@ -191,29 +221,49 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
             </div>
             </div>
           )}
           )}
 
 
-          {/* Quick Controls */}
-          <div className="flex gap-2 mb-3">
-            <Button
-              size="sm"
-              variant={isOn ? 'primary' : 'secondary'}
-              disabled={!isReachable || isPending}
-              onClick={() => setShowPowerOnConfirm(true)}
-              className="flex-1"
-            >
-              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
-              On
-            </Button>
-            <Button
-              size="sm"
-              variant={!isOn ? 'primary' : 'secondary'}
-              disabled={!isReachable || isPending}
-              onClick={() => setShowPowerOffConfirm(true)}
-              className="flex-1"
-            >
-              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
-              Off
-            </Button>
-          </div>
+          {/* Quick Controls - hidden for MQTT plugs (monitor-only) */}
+          {plug.plug_type !== 'mqtt' && (
+            <div className="flex gap-2 mb-3">
+              <Button
+                size="sm"
+                variant={isOn ? 'primary' : 'secondary'}
+                disabled={!isReachable || isPending}
+                onClick={() => setShowPowerOnConfirm(true)}
+                className="flex-1"
+              >
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+                On
+              </Button>
+              <Button
+                size="sm"
+                variant={!isOn ? 'primary' : 'secondary'}
+                disabled={!isReachable || isPending}
+                onClick={() => setShowPowerOffConfirm(true)}
+                className="flex-1"
+              >
+                {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+                Off
+              </Button>
+            </div>
+          )}
+
+          {/* Energy display for MQTT plugs */}
+          {plug.plug_type === 'mqtt' && status?.energy && (
+            <div className="flex gap-2 mb-3 px-3 py-2 bg-bambu-dark rounded-lg">
+              {status.energy.power !== null && status.energy.power !== undefined && (
+                <div className="flex-1 text-center">
+                  <p className="text-lg font-semibold text-white">{Math.round(status.energy.power)}W</p>
+                  <p className="text-xs text-bambu-gray">Power</p>
+                </div>
+              )}
+              {status.energy.today !== null && status.energy.today !== undefined && (
+                <div className="flex-1 text-center border-l border-bambu-dark-tertiary">
+                  <p className="text-lg font-semibold text-white">{status.energy.today.toFixed(2)}</p>
+                  <p className="text-xs text-bambu-gray">kWh Today</p>
+                </div>
+              )}
+            </div>
+          )}
 
 
           {/* Toggle Settings Panel */}
           {/* Toggle Settings Panel */}
           <button
           <button
@@ -222,7 +272,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
           >
           >
             <span className="flex items-center gap-2">
             <span className="flex items-center gap-2">
               <Settings2 className="w-4 h-4" />
               <Settings2 className="w-4 h-4" />
-              Automation Settings
+              {plug.plug_type === 'mqtt' ? 'Settings' : 'Automation Settings'}
             </span>
             </span>
             <span>{isExpanded ? '-' : '+'}</span>
             <span>{isExpanded ? '-' : '+'}</span>
           </button>
           </button>
@@ -250,45 +300,48 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 </label>
                 </label>
               </div>
               </div>
 
 
-              {/* Enabled Toggle */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-sm text-white">Enabled</p>
-                  <p className="text-xs text-bambu-gray">Enable automation for this plug</p>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={plug.enabled}
-                    onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
-                    className="sr-only peer"
-                  />
-                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                </label>
-              </div>
+              {/* Automation controls - only for controllable plugs (not MQTT) */}
+              {plug.plug_type !== 'mqtt' && (
+                <>
+                  {/* Enabled Toggle */}
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-sm text-white">Enabled</p>
+                      <p className="text-xs text-bambu-gray">Enable automation for this plug</p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={plug.enabled}
+                        onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
+                        className="sr-only peer"
+                      />
+                      <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                    </label>
+                  </div>
 
 
-              {/* Auto On */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-sm text-white">Auto On</p>
-                  <p className="text-xs text-bambu-gray">Turn on when print starts</p>
-                </div>
-                <label className="relative inline-flex items-center cursor-pointer">
-                  <input
-                    type="checkbox"
-                    checked={plug.auto_on}
-                    onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
-                    className="sr-only peer"
-                  />
-                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                </label>
-              </div>
+                  {/* Auto On */}
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-sm text-white">Auto On</p>
+                      <p className="text-xs text-bambu-gray">Turn on when print starts</p>
+                    </div>
+                    <label className="relative inline-flex items-center cursor-pointer">
+                      <input
+                        type="checkbox"
+                        checked={plug.auto_on}
+                        onChange={(e) => updateMutation.mutate({ auto_on: e.target.checked })}
+                        className="sr-only peer"
+                      />
+                      <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                    </label>
+                  </div>
 
 
-              {/* Auto Off */}
-              <div className="flex items-center justify-between">
-                <div>
-                  <p className="text-sm text-white">Auto Off</p>
-                  <p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
+                  {/* Auto Off */}
+                  <div className="flex items-center justify-between">
+                    <div>
+                      <p className="text-sm text-white">Auto Off</p>
+                      <p className="text-xs text-bambu-gray">Turn off when print completes (one-shot)</p>
                 </div>
                 </div>
                 <label className="relative inline-flex items-center cursor-pointer">
                 <label className="relative inline-flex items-center cursor-pointer">
                   <input
                   <input
@@ -360,6 +413,8 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                   )}
                   )}
                 </div>
                 </div>
               )}
               )}
+                </>
+              )}
 
 
               {/* Action Buttons */}
               {/* Action Buttons */}
               <div className="flex gap-2 pt-2">
               <div className="flex gap-2 pt-2">

+ 60 - 30
frontend/src/components/SwitchbarPopover.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap } from 'lucide-react';
+import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap, Radio, Eye } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { SmartPlug } from '../api/client';
 import type { SmartPlug } from '../api/client';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
@@ -29,8 +29,11 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
   });
   });
 
 
   const isOn = status?.state === 'ON';
   const isOn = status?.state === 'ON';
-  const isReachable = status?.reachable ?? false;
+  // For MQTT plugs, consider reachable if we have power data
+  const hasMqttData = plug.plug_type === 'mqtt' && (status?.energy?.power !== null && status?.energy?.power !== undefined);
+  const isReachable = (status?.reachable ?? false) || hasMqttData;
   const isPending = controlMutation.isPending;
   const isPending = controlMutation.isPending;
+  const isMqtt = plug.plug_type === 'mqtt';
 
 
   const handleConfirm = () => {
   const handleConfirm = () => {
     if (confirmAction) {
     if (confirmAction) {
@@ -43,14 +46,38 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
     <>
     <>
       <div className="flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
       <div className="flex items-center justify-between py-2 px-3 hover:bg-bambu-dark-tertiary rounded-lg transition-colors">
         <div className="flex items-center gap-2">
         <div className="flex items-center gap-2">
-          <div className={`p-1.5 rounded ${isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20'}`}>
-            <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+          <div className={`p-1.5 rounded ${
+            isMqtt
+              ? (isReachable ? 'bg-teal-500/20' : 'bg-red-500/20')
+              : (isReachable ? (isOn ? 'bg-bambu-green/20' : 'bg-bambu-dark') : 'bg-red-500/20')
+          }`}>
+            {isMqtt ? (
+              <Radio className={`w-4 h-4 ${isReachable ? 'text-teal-400' : 'text-red-400'}`} />
+            ) : (
+              <Plug className={`w-4 h-4 ${isReachable ? (isOn ? 'text-bambu-green' : 'text-bambu-gray') : 'text-red-400'}`} />
+            )}
           </div>
           </div>
           <div>
           <div>
             <p className="text-sm text-white font-medium">{plug.name}</p>
             <p className="text-sm text-white font-medium">{plug.name}</p>
             <div className="flex items-center gap-1 text-xs">
             <div className="flex items-center gap-1 text-xs">
               {statusLoading ? (
               {statusLoading ? (
                 <Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
                 <Loader2 className="w-3 h-3 text-bambu-gray animate-spin" />
+              ) : isMqtt ? (
+                /* MQTT plugs show power and monitor-only indicator */
+                isReachable ? (
+                  <>
+                    <Zap className="w-3 h-3 text-teal-400" />
+                    <span className="text-teal-400">{Math.round(status?.energy?.power ?? 0)}W</span>
+                    <span className="text-bambu-gray mx-1">|</span>
+                    <Eye className="w-3 h-3 text-bambu-gray" />
+                    <span className="text-bambu-gray">Monitor</span>
+                  </>
+                ) : (
+                  <>
+                    <WifiOff className="w-3 h-3 text-status-error" />
+                    <span className="text-status-error">Waiting</span>
+                  </>
+                )
               ) : isReachable ? (
               ) : isReachable ? (
                 <>
                 <>
                   <Wifi className="w-3 h-3 text-status-ok" />
                   <Wifi className="w-3 h-3 text-status-ok" />
@@ -73,32 +100,35 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
           </div>
           </div>
         </div>
         </div>
 
 
-        <div className="flex gap-1">
-          <button
-            onClick={() => setConfirmAction('on')}
-            disabled={!isReachable || isPending}
-            className={`p-1.5 rounded transition-colors ${
-              isOn
-                ? 'bg-bambu-green text-white'
-                : 'bg-bambu-dark text-bambu-gray hover:text-white'
-            } disabled:opacity-50 disabled:cursor-not-allowed`}
-            title="Turn On"
-          >
-            {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
-          </button>
-          <button
-            onClick={() => setConfirmAction('off')}
-            disabled={!isReachable || isPending}
-            className={`p-1.5 rounded transition-colors ${
-              !isOn && isReachable
-                ? 'bg-bambu-dark-tertiary text-white'
-                : 'bg-bambu-dark text-bambu-gray hover:text-white'
-            } disabled:opacity-50 disabled:cursor-not-allowed`}
-            title="Turn Off"
-          >
-            {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
-          </button>
-        </div>
+        {/* Hide on/off buttons for MQTT plugs (monitor-only) */}
+        {!isMqtt && (
+          <div className="flex gap-1">
+            <button
+              onClick={() => setConfirmAction('on')}
+              disabled={!isReachable || isPending}
+              className={`p-1.5 rounded transition-colors ${
+                isOn
+                  ? 'bg-bambu-green text-white'
+                  : 'bg-bambu-dark text-bambu-gray hover:text-white'
+              } disabled:opacity-50 disabled:cursor-not-allowed`}
+              title="Turn On"
+            >
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Power className="w-4 h-4" />}
+            </button>
+            <button
+              onClick={() => setConfirmAction('off')}
+              disabled={!isReachable || isPending}
+              className={`p-1.5 rounded transition-colors ${
+                !isOn && isReachable
+                  ? 'bg-bambu-dark-tertiary text-white'
+                  : 'bg-bambu-dark text-bambu-gray hover:text-white'
+              } disabled:opacity-50 disabled:cursor-not-allowed`}
+              title="Turn Off"
+            >
+              {isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <PowerOff className="w-4 h-4" />}
+            </button>
+          </div>
+        )}
       </div>
       </div>
 
 
       {confirmAction && (
       {confirmAction && (

+ 294 - 0
frontend/src/components/TagManagementModal.tsx

@@ -0,0 +1,294 @@
+import { useState, useEffect } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Tag, Pencil, Trash2, Loader2, Search, Check, AlertTriangle } from 'lucide-react';
+import { api } from '../api/client';
+import type { TagInfo } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+
+interface TagManagementModalProps {
+  onClose: () => void;
+}
+
+export function TagManagementModal({ onClose }: TagManagementModalProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [search, setSearch] = useState('');
+  const [editingTag, setEditingTag] = useState<string | null>(null);
+  const [editValue, setEditValue] = useState('');
+  const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
+  const [sortBy, setSortBy] = useState<'count' | 'name'>('count');
+
+  // Close on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') {
+        if (editingTag) {
+          setEditingTag(null);
+        } else if (deleteConfirm) {
+          setDeleteConfirm(null);
+        } else {
+          onClose();
+        }
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose, editingTag, deleteConfirm]);
+
+  const { data: tags, isLoading } = useQuery({
+    queryKey: ['tags'],
+    queryFn: api.getTags,
+  });
+
+  const renameMutation = useMutation({
+    mutationFn: ({ oldName, newName }: { oldName: string; newName: string }) =>
+      api.renameTag(oldName, newName),
+    onSuccess: (data, { oldName, newName }) => {
+      queryClient.invalidateQueries({ queryKey: ['tags'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Renamed "${oldName}" to "${newName}" in ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
+      setEditingTag(null);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to rename tag', 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (name: string) => api.deleteTag(name),
+    onSuccess: (data, name) => {
+      queryClient.invalidateQueries({ queryKey: ['tags'] });
+      queryClient.invalidateQueries({ queryKey: ['archives'] });
+      showToast(`Deleted "${name}" from ${data.affected} archive${data.affected !== 1 ? 's' : ''}`);
+      setDeleteConfirm(null);
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Failed to delete tag', 'error');
+    },
+  });
+
+  const startEdit = (tag: TagInfo) => {
+    setEditingTag(tag.name);
+    setEditValue(tag.name);
+    setDeleteConfirm(null);
+  };
+
+  const cancelEdit = () => {
+    setEditingTag(null);
+    setEditValue('');
+  };
+
+  const submitEdit = () => {
+    if (!editingTag || !editValue.trim()) return;
+    const newName = editValue.trim();
+    if (newName === editingTag) {
+      cancelEdit();
+      return;
+    }
+    renameMutation.mutate({ oldName: editingTag, newName });
+  };
+
+  const handleEditKeyDown = (e: React.KeyboardEvent) => {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      submitEdit();
+    } else if (e.key === 'Escape') {
+      e.preventDefault();
+      cancelEdit();
+    }
+  };
+
+  const confirmDelete = (name: string) => {
+    setDeleteConfirm(name);
+    setEditingTag(null);
+  };
+
+  const executeDelete = () => {
+    if (deleteConfirm) {
+      deleteMutation.mutate(deleteConfirm);
+    }
+  };
+
+  // Filter and sort tags
+  const filteredTags = tags
+    ?.filter(t => t.name.toLowerCase().includes(search.toLowerCase()))
+    .sort((a, b) => {
+      if (sortBy === 'count') {
+        return b.count - a.count || a.name.localeCompare(b.name);
+      }
+      return a.name.localeCompare(b.name);
+    });
+
+  const totalUsage = tags?.reduce((sum, t) => sum + t.count, 0) || 0;
+
+  return (
+    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+      <Card className="w-full max-w-lg max-h-[80vh] flex flex-col">
+        <CardContent className="p-0 flex flex-col min-h-0">
+          {/* Header */}
+          <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+            <div className="flex items-center gap-2">
+              <Tag className="w-5 h-5 text-bambu-green" />
+              <h2 className="text-xl font-semibold text-white">Manage Tags</h2>
+            </div>
+            <button
+              onClick={onClose}
+              className="text-bambu-gray hover:text-white transition-colors"
+            >
+              <X className="w-5 h-5" />
+            </button>
+          </div>
+
+          {/* Search and sort */}
+          <div className="p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
+            <div className="flex gap-2">
+              <div className="relative flex-1">
+                <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                <input
+                  type="text"
+                  placeholder="Search tags..."
+                  className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                  value={search}
+                  onChange={(e) => setSearch(e.target.value)}
+                />
+              </div>
+              <select
+                className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                value={sortBy}
+                onChange={(e) => setSortBy(e.target.value as 'count' | 'name')}
+              >
+                <option value="count">Sort by Count</option>
+                <option value="name">Sort by Name</option>
+              </select>
+            </div>
+            {tags && (
+              <p className="text-xs text-bambu-gray mt-2">
+                {tags.length} tag{tags.length !== 1 ? 's' : ''} across {totalUsage} usage{totalUsage !== 1 ? 's' : ''}
+              </p>
+            )}
+          </div>
+
+          {/* Tags list */}
+          <div className="flex-1 overflow-y-auto min-h-0 p-4">
+            {isLoading ? (
+              <div className="flex items-center justify-center py-8">
+                <Loader2 className="w-6 h-6 animate-spin text-bambu-gray" />
+              </div>
+            ) : !filteredTags?.length ? (
+              <div className="text-center py-8 text-bambu-gray">
+                {search ? 'No tags match your search' : 'No tags found'}
+              </div>
+            ) : (
+              <div className="space-y-2">
+                {filteredTags.map((tag) => (
+                  <div
+                    key={tag.name}
+                    className="flex items-center gap-2 p-2 rounded-lg bg-bambu-dark hover:bg-bambu-dark-tertiary transition-colors group"
+                  >
+                    {editingTag === tag.name ? (
+                      // Edit mode
+                      <div className="flex-1 flex items-center gap-2">
+                        <input
+                          type="text"
+                          className="flex-1 px-2 py-1 bg-bambu-dark-tertiary border border-bambu-green rounded text-white text-sm focus:outline-none"
+                          value={editValue}
+                          onChange={(e) => setEditValue(e.target.value)}
+                          onKeyDown={handleEditKeyDown}
+                          autoFocus
+                        />
+                        <Button
+                          size="sm"
+                          variant="primary"
+                          onClick={submitEdit}
+                          disabled={!editValue.trim() || renameMutation.isPending}
+                          className="p-1.5"
+                        >
+                          {renameMutation.isPending ? (
+                            <Loader2 className="w-4 h-4 animate-spin" />
+                          ) : (
+                            <Check className="w-4 h-4" />
+                          )}
+                        </Button>
+                        <Button
+                          size="sm"
+                          variant="ghost"
+                          onClick={cancelEdit}
+                          className="p-1.5"
+                        >
+                          <X className="w-4 h-4" />
+                        </Button>
+                      </div>
+                    ) : deleteConfirm === tag.name ? (
+                      // Delete confirmation
+                      <div className="flex-1 flex items-center gap-2">
+                        <AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
+                        <span className="text-sm text-bambu-gray-light flex-1">
+                          Delete "{tag.name}" from {tag.count} archive{tag.count !== 1 ? 's' : ''}?
+                        </span>
+                        <Button
+                          size="sm"
+                          variant="danger"
+                          onClick={executeDelete}
+                          disabled={deleteMutation.isPending}
+                          className="p-1.5"
+                        >
+                          {deleteMutation.isPending ? (
+                            <Loader2 className="w-4 h-4 animate-spin" />
+                          ) : (
+                            <Trash2 className="w-4 h-4" />
+                          )}
+                        </Button>
+                        <Button
+                          size="sm"
+                          variant="ghost"
+                          onClick={() => setDeleteConfirm(null)}
+                          className="p-1.5"
+                        >
+                          <X className="w-4 h-4" />
+                        </Button>
+                      </div>
+                    ) : (
+                      // Normal display
+                      <>
+                        <Tag className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                        <span className="text-white flex-1 truncate">{tag.name}</span>
+                        <span className="px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray text-xs">
+                          {tag.count}
+                        </span>
+                        <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
+                          <button
+                            onClick={() => startEdit(tag)}
+                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                            title="Rename tag"
+                          >
+                            <Pencil className="w-4 h-4" />
+                          </button>
+                          <button
+                            onClick={() => confirmDelete(tag.name)}
+                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
+                            title="Delete tag"
+                          >
+                            <Trash2 className="w-4 h-4" />
+                          </button>
+                        </div>
+                      </>
+                    )}
+                  </div>
+                ))}
+              </div>
+            )}
+          </div>
+
+          {/* Footer */}
+          <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary flex-shrink-0">
+            <Button variant="secondary" onClick={onClose} className="flex-1">
+              Close
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 40 - 2
frontend/src/contexts/AuthContext.tsx

@@ -1,16 +1,20 @@
-import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
+import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
 import { api, getAuthToken, setAuthToken } from '../api/client';
 import { api, getAuthToken, setAuthToken } from '../api/client';
-import type { UserResponse } from '../api/client';
+import type { Permission, UserResponse } from '../api/client';
 
 
 interface AuthContextType {
 interface AuthContextType {
   user: UserResponse | null;
   user: UserResponse | null;
   authEnabled: boolean;
   authEnabled: boolean;
   requiresSetup: boolean;
   requiresSetup: boolean;
   loading: boolean;
   loading: boolean;
+  isAdmin: boolean;
   login: (username: string, password: string) => Promise<void>;
   login: (username: string, password: string) => Promise<void>;
   logout: () => void;
   logout: () => void;
   refreshUser: () => Promise<void>;
   refreshUser: () => Promise<void>;
   refreshAuth: () => Promise<void>;
   refreshAuth: () => Promise<void>;
+  hasPermission: (permission: Permission) => boolean;
+  hasAnyPermission: (...permissions: Permission[]) => boolean;
+  hasAllPermissions: (...permissions: Permission[]) => boolean;
 }
 }
 
 
 const AuthContext = createContext<AuthContextType | undefined>(undefined);
 const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -122,6 +126,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     await checkAuthStatus();
     await checkAuthStatus();
   };
   };
 
 
+  // Memoize permission set for efficient lookups
+  const permissionSet = useMemo(() => {
+    return new Set(user?.permissions ?? []);
+  }, [user?.permissions]);
+
+  // Computed admin status
+  const isAdmin = useMemo(() => {
+    if (!authEnabled) return true; // Auth disabled = admin access
+    return user?.is_admin ?? false;
+  }, [authEnabled, user?.is_admin]);
+
+  // Permission check functions
+  const hasPermission = useCallback((permission: Permission): boolean => {
+    if (!authEnabled) return true; // Auth disabled = allow all
+    if (isAdmin) return true; // Admins have all permissions
+    return permissionSet.has(permission);
+  }, [authEnabled, isAdmin, permissionSet]);
+
+  const hasAnyPermission = useCallback((...permissions: Permission[]): boolean => {
+    if (!authEnabled) return true;
+    if (isAdmin) return true;
+    return permissions.some(p => permissionSet.has(p));
+  }, [authEnabled, isAdmin, permissionSet]);
+
+  const hasAllPermissions = useCallback((...permissions: Permission[]): boolean => {
+    if (!authEnabled) return true;
+    if (isAdmin) return true;
+    return permissions.every(p => permissionSet.has(p));
+  }, [authEnabled, isAdmin, permissionSet]);
+
   return (
   return (
     <AuthContext.Provider
     <AuthContext.Provider
       value={{
       value={{
@@ -129,10 +163,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         authEnabled,
         authEnabled,
         requiresSetup,
         requiresSetup,
         loading,
         loading,
+        isAdmin,
         login,
         login,
         logout,
         logout,
         refreshUser,
         refreshUser,
         refreshAuth,
         refreshAuth,
+        hasPermission,
+        hasAnyPermission,
+        hasAllPermissions,
       }}
       }}
     >
     >
       {children}
       {children}

+ 109 - 12
frontend/src/pages/ArchivesPage.tsx

@@ -43,6 +43,7 @@ import {
   FolderKanban,
   FolderKanban,
   ChevronLeft,
   ChevronLeft,
   ChevronRight,
   ChevronRight,
+  Settings,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { openInSlicer } from '../utils/slicer';
 import { openInSlicer } from '../utils/slicer';
@@ -66,7 +67,9 @@ import { ProjectPageModal } from '../components/ProjectPageModal';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { TimelapseViewer } from '../components/TimelapseViewer';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
+import { TagManagementModal } from '../components/TagManagementModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 
 function formatFileSize(bytes: number): string {
 function formatFileSize(bytes: number): string {
   if (bytes < 1024) return `${bytes} B`;
   if (bytes < 1024) return `${bytes} B`;
@@ -120,6 +123,7 @@ function ArchiveCard({
 
 
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
   const [showViewer, setShowViewer] = useState(false);
   const [showViewer, setShowViewer] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
@@ -282,11 +286,15 @@ function ArchiveCard({
         label: 'Print',
         label: 'Print',
         icon: <Printer className="w-4 h-4" />,
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
         onClick: () => setShowReprint(true),
+        disabled: !hasPermission('archives:reprint'),
+        title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined,
       },
       },
       {
       {
         label: 'Schedule',
         label: 'Schedule',
         icon: <Calendar className="w-4 h-4" />,
         icon: <Calendar className="w-4 h-4" />,
         onClick: () => setShowSchedule(true),
         onClick: () => setShowSchedule(true),
+        disabled: !hasPermission('queue:create'),
+        title: !hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined,
       },
       },
       {
       {
         label: 'Open in Bambu Studio',
         label: 'Open in Bambu Studio',
@@ -333,7 +341,8 @@ function ArchiveCard({
       label: 'Scan for Timelapse',
       label: 'Scan for Timelapse',
       icon: <ScanSearch className="w-4 h-4" />,
       icon: <ScanSearch className="w-4 h-4" />,
       onClick: () => timelapseScanMutation.mutate(),
       onClick: () => timelapseScanMutation.mutate(),
-      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending,
+      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -349,22 +358,30 @@ function ArchiveCard({
           source3mfInputRef.current?.click();
           source3mfInputRef.current?.click();
         }
         }
       },
       },
+      disabled: !archive.source_3mf_path && !hasPermission('archives:update'),
+      title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined,
     },
     },
     ...(archive.source_3mf_path ? [{
     ...(archive.source_3mf_path ? [{
       label: 'Replace Source 3MF',
       label: 'Replace Source 3MF',
       icon: <Upload className="w-4 h-4" />,
       icon: <Upload className="w-4 h-4" />,
       onClick: () => source3mfInputRef.current?.click(),
       onClick: () => source3mfInputRef.current?.click(),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     {
     {
       label: 'Remove Source 3MF',
       label: 'Remove Source 3MF',
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteSource3mfConfirm(true),
       onClick: () => setShowDeleteSource3mfConfirm(true),
       danger: true,
       danger: true,
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     }] : []),
     }] : []),
     {
     {
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
       onClick: () => f3dInputRef.current?.click(),
       onClick: () => f3dInputRef.current?.click(),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     ...(archive.f3d_path ? [{
     ...(archive.f3d_path ? [{
       label: 'Download F3D',
       label: 'Download F3D',
@@ -381,6 +398,8 @@ function ArchiveCard({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteF3dConfirm(true),
       onClick: () => setShowDeleteF3dConfirm(true),
       danger: true,
       danger: true,
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     }] : []),
     }] : []),
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -392,6 +411,8 @@ function ArchiveCard({
         link.download = `${archive.print_name || archive.filename}.3mf`;
         link.download = `${archive.print_name || archive.filename}.3mf`;
         link.click();
         link.click();
       },
       },
+      disabled: !hasPermission('archives:read'),
+      title: !hasPermission('archives:read') ? 'You do not have permission to download archives' : undefined,
     },
     },
     {
     {
       label: 'Copy Download Link',
       label: 'Copy Download Link',
@@ -404,6 +425,8 @@ function ArchiveCard({
           showToast('Failed to copy link', 'error');
           showToast('Failed to copy link', 'error');
         });
         });
       },
       },
+      disabled: !hasPermission('archives:read'),
+      title: !hasPermission('archives:read') ? 'You do not have permission to copy download links' : undefined,
     },
     },
     {
     {
       label: 'QR Code',
       label: 'QR Code',
@@ -426,11 +449,15 @@ function ArchiveCard({
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       onClick: () => favoriteMutation.mutate(),
       onClick: () => favoriteMutation.mutate(),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     {
     {
       label: 'Edit',
       label: 'Edit',
       icon: <Pencil className="w-4 h-4" />,
       icon: <Pencil className="w-4 h-4" />,
       onClick: () => setShowEdit(true),
       onClick: () => setShowEdit(true),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     ...(archive.project_id && archive.project_name ? [{
     ...(archive.project_id && archive.project_name ? [{
       label: `Go to Project: ${archive.project_name}`,
       label: `Go to Project: ${archive.project_name}`,
@@ -441,6 +468,8 @@ function ArchiveCard({
       label: 'Add to Project',
       label: 'Add to Project',
       icon: <FolderKanban className="w-4 h-4" />,
       icon: <FolderKanban className="w-4 h-4" />,
       onClick: () => {},
       onClick: () => {},
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
       submenu: (() => {
       submenu: (() => {
         const items: ContextMenuItem[] = [];
         const items: ContextMenuItem[] = [];
 
 
@@ -450,6 +479,7 @@ function ArchiveCard({
             label: 'Remove from Project',
             label: 'Remove from Project',
             icon: <X className="w-4 h-4" />,
             icon: <X className="w-4 h-4" />,
             onClick: () => assignProjectMutation.mutate(null),
             onClick: () => assignProjectMutation.mutate(null),
+            disabled: !hasPermission('archives:update'),
           });
           });
         }
         }
 
 
@@ -476,7 +506,7 @@ function ArchiveCard({
                 label: p.name,
                 label: p.name,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
                 onClick: () => assignProjectMutation.mutate(p.id),
                 onClick: () => assignProjectMutation.mutate(p.id),
-                disabled: archive.project_id === p.id,
+                disabled: archive.project_id === p.id || !hasPermission('archives:update'),
               });
               });
             });
             });
           }
           }
@@ -496,6 +526,8 @@ function ArchiveCard({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteConfirm(true),
       onClick: () => setShowDeleteConfirm(true),
       danger: true,
       danger: true,
+      disabled: !hasPermission('archives:delete'),
+      title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined,
     },
     },
   ];
   ];
 
 
@@ -615,15 +647,22 @@ function ArchiveCard({
         </button>
         </button>
         {/* Favorite star */}
         {/* Favorite star */}
         <button
         <button
-          className="absolute top-2 right-2 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
+          className={`absolute top-2 right-2 p-1 rounded transition-colors ${
+            hasPermission('archives:update')
+              ? 'bg-black/50 hover:bg-black/70'
+              : 'bg-black/30 cursor-not-allowed'
+          }`}
           onClick={(e) => {
           onClick={(e) => {
             e.stopPropagation();
             e.stopPropagation();
-            favoriteMutation.mutate();
+            if (hasPermission('archives:update')) {
+              favoriteMutation.mutate();
+            }
           }}
           }}
-          title={archive.is_favorite ? 'Remove from favorites' : 'Add to favorites'}
+          disabled={!hasPermission('archives:update')}
+          title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : (archive.is_favorite ? 'Remove from favorites' : 'Add to favorites')}
         >
         >
           <Star
           <Star
-            className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'}`}
+            className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'} ${!hasPermission('archives:update') ? 'opacity-50' : ''}`}
           />
           />
         </button>
         </button>
         {(archive.status === 'failed' || archive.status === 'aborted') && (
         {(archive.status === 'failed' || archive.status === 'aborted') && (
@@ -863,6 +902,8 @@ function ArchiveCard({
                 size="sm"
                 size="sm"
                 className="flex-1 min-w-0"
                 className="flex-1 min-w-0"
                 onClick={() => setShowReprint(true)}
                 onClick={() => setShowReprint(true)}
+                disabled={!hasPermission('archives:reprint')}
+                title={!hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined}
               >
               >
                 <Printer className="w-3 h-3 flex-shrink-0" />
                 <Printer className="w-3 h-3 flex-shrink-0" />
                 <span className="hidden sm:inline">Reprint</span>
                 <span className="hidden sm:inline">Reprint</span>
@@ -945,7 +986,8 @@ function ArchiveCard({
             size="sm"
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             className="min-w-0 p-1 sm:p-1.5"
             onClick={() => setShowEdit(true)}
             onClick={() => setShowEdit(true)}
-            title="Edit"
+            disabled={!hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'}
           >
           >
             <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
             <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
           </Button>
           </Button>
@@ -954,7 +996,8 @@ function ArchiveCard({
             size="sm"
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             className="min-w-0 p-1 sm:p-1.5"
             onClick={() => setShowDeleteConfirm(true)}
             onClick={() => setShowDeleteConfirm(true)}
-            title="Delete"
+            disabled={!hasPermission('archives:delete')}
+            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'}
           >
           >
             <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
             <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
           </Button>
           </Button>
@@ -1211,6 +1254,7 @@ function ArchiveListRow({
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [showEdit, setShowEdit] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
@@ -1355,11 +1399,15 @@ function ArchiveListRow({
         label: 'Print',
         label: 'Print',
         icon: <Printer className="w-4 h-4" />,
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
         onClick: () => setShowReprint(true),
+        disabled: !hasPermission('archives:reprint'),
+        title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined,
       },
       },
       {
       {
         label: 'Schedule',
         label: 'Schedule',
         icon: <Calendar className="w-4 h-4" />,
         icon: <Calendar className="w-4 h-4" />,
         onClick: () => setShowSchedule(true),
         onClick: () => setShowSchedule(true),
+        disabled: !hasPermission('queue:create'),
+        title: !hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined,
       },
       },
       {
       {
         label: 'Open in Bambu Studio',
         label: 'Open in Bambu Studio',
@@ -1406,7 +1454,8 @@ function ArchiveListRow({
       label: 'Scan for Timelapse',
       label: 'Scan for Timelapse',
       icon: <ScanSearch className="w-4 h-4" />,
       icon: <ScanSearch className="w-4 h-4" />,
       onClick: () => timelapseScanMutation.mutate(),
       onClick: () => timelapseScanMutation.mutate(),
-      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending,
+      disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -1422,22 +1471,30 @@ function ArchiveListRow({
           source3mfInputRef.current?.click();
           source3mfInputRef.current?.click();
         }
         }
       },
       },
+      disabled: !archive.source_3mf_path && !hasPermission('archives:update'),
+      title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined,
     },
     },
     ...(archive.source_3mf_path ? [{
     ...(archive.source_3mf_path ? [{
       label: 'Replace Source 3MF',
       label: 'Replace Source 3MF',
       icon: <Upload className="w-4 h-4" />,
       icon: <Upload className="w-4 h-4" />,
       onClick: () => source3mfInputRef.current?.click(),
       onClick: () => source3mfInputRef.current?.click(),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     {
     {
       label: 'Remove Source 3MF',
       label: 'Remove Source 3MF',
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteSource3mfConfirm(true),
       onClick: () => setShowDeleteSource3mfConfirm(true),
       danger: true,
       danger: true,
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     }] : []),
     }] : []),
     {
     {
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
       onClick: () => f3dInputRef.current?.click(),
       onClick: () => f3dInputRef.current?.click(),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     ...(archive.f3d_path ? [{
     ...(archive.f3d_path ? [{
       label: 'Download F3D',
       label: 'Download F3D',
@@ -1454,6 +1511,8 @@ function ArchiveListRow({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteF3dConfirm(true),
       onClick: () => setShowDeleteF3dConfirm(true),
       danger: true,
       danger: true,
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     }] : []),
     }] : []),
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -1465,6 +1524,8 @@ function ArchiveListRow({
         link.download = `${archive.print_name || archive.filename}.3mf`;
         link.download = `${archive.print_name || archive.filename}.3mf`;
         link.click();
         link.click();
       },
       },
+      disabled: !hasPermission('archives:read'),
+      title: !hasPermission('archives:read') ? 'You do not have permission to download archives' : undefined,
     },
     },
     {
     {
       label: 'Copy Download Link',
       label: 'Copy Download Link',
@@ -1477,6 +1538,8 @@ function ArchiveListRow({
           showToast('Failed to copy link', 'error');
           showToast('Failed to copy link', 'error');
         });
         });
       },
       },
+      disabled: !hasPermission('archives:read'),
+      title: !hasPermission('archives:read') ? 'You do not have permission to copy download links' : undefined,
     },
     },
     {
     {
       label: 'QR Code',
       label: 'QR Code',
@@ -1499,11 +1562,15 @@ function ArchiveListRow({
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       onClick: () => favoriteMutation.mutate(),
       onClick: () => favoriteMutation.mutate(),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     {
     {
       label: 'Edit',
       label: 'Edit',
       icon: <Pencil className="w-4 h-4" />,
       icon: <Pencil className="w-4 h-4" />,
       onClick: () => setShowEdit(true),
       onClick: () => setShowEdit(true),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     ...(archive.project_id && archive.project_name ? [{
     ...(archive.project_id && archive.project_name ? [{
       label: `Go to Project: ${archive.project_name}`,
       label: `Go to Project: ${archive.project_name}`,
@@ -1564,6 +1631,8 @@ function ArchiveListRow({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteConfirm(true),
       onClick: () => setShowDeleteConfirm(true),
       danger: true,
       danger: true,
+      disabled: !hasPermission('archives:delete'),
+      title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined,
     },
     },
   ];
   ];
 
 
@@ -1696,7 +1765,8 @@ function ArchiveListRow({
             variant="ghost"
             variant="ghost"
             size="sm"
             size="sm"
             onClick={() => setShowEdit(true)}
             onClick={() => setShowEdit(true)}
-            title="Edit"
+            disabled={!hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'}
           >
           >
             <Pencil className="w-4 h-4" />
             <Pencil className="w-4 h-4" />
           </Button>
           </Button>
@@ -1704,7 +1774,8 @@ function ArchiveListRow({
             variant="ghost"
             variant="ghost"
             size="sm"
             size="sm"
             onClick={() => setShowDeleteConfirm(true)}
             onClick={() => setShowDeleteConfirm(true)}
-            title="Delete"
+            disabled={!hasPermission('archives:delete')}
+            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'}
           >
           >
             <Trash2 className="w-4 h-4 text-red-400" />
             <Trash2 className="w-4 h-4 text-red-400" />
           </Button>
           </Button>
@@ -1958,6 +2029,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
 export function ArchivesPage() {
 export function ArchivesPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const searchInputRef = useRef<HTMLInputElement>(null);
   const searchInputRef = useRef<HTMLInputElement>(null);
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
   const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
   const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
@@ -2006,6 +2078,7 @@ export function ArchivesPage() {
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [showCompareModal, setShowCompareModal] = useState(false);
   const [showCompareModal, setShowCompareModal] = useState(false);
+  const [showTagManagement, setShowTagManagement] = useState(false);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
   const [highlightedArchiveId, setHighlightedArchiveId] = useState<number | null>(null);
 
 
   // Clear highlight after 5 seconds and scroll to highlighted element
   // Clear highlight after 5 seconds and scroll to highlighted element
@@ -2372,6 +2445,8 @@ export function ArchivesPage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowBatchTag(true)}
             onClick={() => setShowBatchTag(true)}
+            disabled={!hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
           >
           >
             <Tag className="w-4 h-4" />
             <Tag className="w-4 h-4" />
             Tags
             Tags
@@ -2380,6 +2455,8 @@ export function ArchivesPage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowBatchProject(true)}
             onClick={() => setShowBatchProject(true)}
+            disabled={!hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
           >
           >
             <FolderKanban className="w-4 h-4" />
             <FolderKanban className="w-4 h-4" />
             Project
             Project
@@ -2387,6 +2464,8 @@ export function ArchivesPage() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
+            disabled={!hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
             onClick={() => {
             onClick={() => {
               const ids = Array.from(selectedIds);
               const ids = Array.from(selectedIds);
               Promise.all(ids.map(id => api.toggleFavorite(id)))
               Promise.all(ids.map(id => api.toggleFavorite(id)))
@@ -2406,6 +2485,8 @@ export function ArchivesPage() {
             size="sm"
             size="sm"
             className="bg-red-500 hover:bg-red-600"
             className="bg-red-500 hover:bg-red-600"
             onClick={() => setShowBulkDeleteConfirm(true)}
             onClick={() => setShowBulkDeleteConfirm(true)}
+            disabled={!hasPermission('archives:delete')}
+            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined}
           >
           >
             <Trash2 className="w-4 h-4" />
             <Trash2 className="w-4 h-4" />
             Delete
             Delete
@@ -2527,7 +2608,11 @@ export function ArchivesPage() {
               Select
               Select
             </Button>
             </Button>
           )}
           )}
-          <Button onClick={() => setShowUpload(true)}>
+          <Button
+            onClick={() => setShowUpload(true)}
+            disabled={!hasPermission('archives:create')}
+            title={!hasPermission('archives:create') ? 'You do not have permission to create archives' : undefined}
+          >
             <Upload className="w-4 h-4" />
             <Upload className="w-4 h-4" />
             Upload 3MF
             Upload 3MF
           </Button>
           </Button>
@@ -2637,6 +2722,13 @@ export function ArchivesPage() {
                     </option>
                     </option>
                   ))}
                   ))}
                 </select>
                 </select>
+                <button
+                  onClick={() => setShowTagManagement(true)}
+                  className="p-2 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-green transition-colors"
+                  title="Manage Tags"
+                >
+                  <Settings className="w-4 h-4" />
+                </button>
               </div>
               </div>
             )}
             )}
             <div className="flex items-center gap-2 flex-shrink-0">
             <div className="flex items-center gap-2 flex-shrink-0">
@@ -2865,6 +2957,11 @@ export function ArchivesPage() {
           }}
           }}
         />
         />
       )}
       )}
+
+      {/* Tag Management Modal */}
+      {showTagManagement && (
+        <TagManagementModal onClose={() => setShowTagManagement(false)} />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 371 - 115
frontend/src/pages/FileManagerPage.tsx

@@ -35,6 +35,7 @@ import {
   Printer,
   Printer,
   Pencil,
   Pencil,
   Play,
   Play,
+  Image,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type {
 import type {
@@ -44,11 +45,14 @@ import type {
   LibraryFolderUpdate,
   LibraryFolderUpdate,
   AppSettings,
   AppSettings,
   Archive,
   Archive,
+  Permission,
 } from '../api/client';
 } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useIsMobile } from '../hooks/useIsMobile';
+import { useAuth } from '../contexts/AuthContext';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
@@ -427,6 +431,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
   const [isUploading, setIsUploading] = useState(false);
   const [isUploading, setIsUploading] = useState(false);
   const [preserveZipStructure, setPreserveZipStructure] = useState(true);
   const [preserveZipStructure, setPreserveZipStructure] = useState(true);
   const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
   const [createFolderFromZip, setCreateFolderFromZip] = useState(false);
+  const [generateStlThumbnails, setGenerateStlThumbnails] = useState(true);
   const fileInputRef = useRef<HTMLInputElement>(null);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
 
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -466,6 +471,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
   };
   };
 
 
   const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
   const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
+  const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
 
 
   const handleUpload = async () => {
   const handleUpload = async () => {
     if (files.length === 0) return;
     if (files.length === 0) return;
@@ -482,7 +488,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
       try {
       try {
         if (files[i].isZip) {
         if (files[i].isZip) {
           // Extract ZIP file
           // Extract ZIP file
-          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip);
+          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure, createFolderFromZip, generateStlThumbnails);
           setFiles((prev) =>
           setFiles((prev) =>
             prev.map((f, idx) =>
             prev.map((f, idx) =>
               idx === i
               idx === i
@@ -497,7 +503,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
           );
           );
         } else {
         } else {
           // Regular file upload
           // Regular file upload
-          await api.uploadLibraryFile(files[i].file, folderId);
+          await api.uploadLibraryFile(files[i].file, folderId, generateStlThumbnails);
           setFiles((prev) =>
           setFiles((prev) =>
             prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
             prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
           );
           );
@@ -596,6 +602,32 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
             </div>
             </div>
           )}
           )}
 
 
+          {/* STL Thumbnail Options - show for STL files or ZIP files (which may contain STLs) */}
+          {(hasStlFiles || hasZipFiles) && (
+            <div className="p-3 bg-bambu-green/10 border border-bambu-green/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <Image className="w-5 h-5 text-bambu-green mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-bambu-green font-medium">STL thumbnail generation</p>
+                  <p className="text-xs text-bambu-green/70 mt-1">
+                    {hasZipFiles && !hasStlFiles
+                      ? 'ZIP files may contain STL files. Thumbnails can be generated during extraction.'
+                      : 'Thumbnails can be generated for STL files. Large models may take longer to process.'}
+                  </p>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={generateStlThumbnails}
+                      onChange={(e) => setGenerateStlThumbnails(e.target.checked)}
+                      className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-white">Generate thumbnails for STL files</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+
           {/* File List */}
           {/* File List */}
           {files.length > 0 && (
           {files.length > 0 && (
             <div className="max-h-48 overflow-y-auto space-y-2">
             <div className="max-h-48 overflow-y-auto space-y-2">
@@ -694,9 +726,10 @@ interface FolderTreeItemProps {
   onRename: (folder: LibraryFolderTree) => void;
   onRename: (folder: LibraryFolderTree) => void;
   depth?: number;
   depth?: number;
   wrapNames?: boolean;
   wrapNames?: boolean;
+  hasPermission: (permission: Permission) => boolean;
 }
 }
 
 
-function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false }: FolderTreeItemProps) {
+function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, hasPermission }: FolderTreeItemProps) {
   const [expanded, setExpanded] = useState(true);
   const [expanded, setExpanded] = useState(true);
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
   const hasChildren = folder.children.length > 0;
@@ -769,22 +802,34 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
                 <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
                 <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
                 <div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                 <div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onRename(folder); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:update')) { onRename(folder); setShowActions(false); } }}
+                  disabled={!hasPermission('library:update')}
+                  title={!hasPermission('library:update') ? 'You do not have permission to rename folders' : undefined}
                 >
                 >
                   <Pencil className="w-3.5 h-3.5" />
                   <Pencil className="w-3.5 h-3.5" />
                   Rename
                   Rename
                 </button>
                 </button>
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onLink(folder); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:update')) { onLink(folder); setShowActions(false); } }}
+                  disabled={!hasPermission('library:update')}
+                  title={!hasPermission('library:update') ? 'You do not have permission to link folders' : undefined}
                 >
                 >
                   <Link2 className="w-3.5 h-3.5" />
                   <Link2 className="w-3.5 h-3.5" />
                   {isLinked ? 'Change Link...' : 'Link to...'}
                   {isLinked ? 'Change Link...' : 'Link to...'}
                 </button>
                 </button>
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onDelete(folder.id); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:delete')) { onDelete(folder.id); setShowActions(false); } }}
+                  disabled={!hasPermission('library:delete')}
+                  title={!hasPermission('library:delete') ? 'You do not have permission to delete folders' : undefined}
                 >
                 >
                   <Trash2 className="w-3.5 h-3.5" />
                   <Trash2 className="w-3.5 h-3.5" />
                   Delete
                   Delete
@@ -808,6 +853,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               onRename={onRename}
               onRename={onRename}
               depth={depth + 1}
               depth={depth + 1}
               wrapNames={wrapNames}
               wrapNames={wrapNames}
+              hasPermission={hasPermission}
             />
             />
           ))}
           ))}
         </div>
         </div>
@@ -826,15 +872,19 @@ function isSlicedFilename(filename: string): boolean {
 interface FileCardProps {
 interface FileCardProps {
   file: LibraryFileListItem;
   file: LibraryFileListItem;
   isSelected: boolean;
   isSelected: boolean;
+  isMobile: boolean;
   onSelect: (id: number) => void;
   onSelect: (id: number) => void;
   onDelete: (id: number) => void;
   onDelete: (id: number) => void;
   onDownload: (id: number) => void;
   onDownload: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onAddToQueue?: (id: number) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onPrint?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
+  onGenerateThumbnail?: (file: LibraryFileListItem) => void;
+  thumbnailVersion?: number;
+  hasPermission: (permission: Permission) => boolean;
 }
 }
 
 
-function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
@@ -850,7 +900,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
       <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
       <div className="aspect-square bg-bambu-dark flex items-center justify-center overflow-hidden">
         {file.thumbnail_path ? (
         {file.thumbnail_path ? (
           <img
           <img
-            src={api.getLibraryFileThumbnailUrl(file.id)}
+            src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersion ? `?v=${thumbnailVersion}` : ''}`}
             alt={file.filename}
             alt={file.filename}
             className="w-full h-full object-cover"
             className="w-full h-full object-cover"
           />
           />
@@ -890,7 +940,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
       </div>
       </div>
 
 
       {/* Actions - always visible on mobile, hover on desktop */}
       {/* Actions - always visible on mobile, hover on desktop */}
-      <div className="absolute bottom-2 right-2 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
+      <div className={`absolute bottom-2 right-2 transition-opacity ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`} onClick={(e) => e.stopPropagation()}>
         <button
         <button
           onClick={() => setShowActions(!showActions)}
           onClick={() => setShowActions(!showActions)}
           className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
           className="p-1.5 rounded bg-bambu-dark-secondary/90 hover:bg-bambu-dark-tertiary"
@@ -903,8 +953,12 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
             <div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
             <div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
               {onPrint && isSlicedFilename(file.filename) && (
               {onPrint && isSlicedFilename(file.filename) && (
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-bambu-green hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onPrint(file); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('printers:control') ? 'text-bambu-green hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('printers:control')) { onPrint(file); setShowActions(false); } }}
+                  disabled={!hasPermission('printers:control')}
+                  title={!hasPermission('printers:control') ? 'You do not have permission to print' : undefined}
                 >
                 >
                   <Printer className="w-3.5 h-3.5" />
                   <Printer className="w-3.5 h-3.5" />
                   Print
                   Print
@@ -912,32 +966,61 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
               )}
               )}
               {onAddToQueue && isSlicedFilename(file.filename) && (
               {onAddToQueue && isSlicedFilename(file.filename) && (
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('queue:create') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('queue:create')) { onAddToQueue(file.id); setShowActions(false); } }}
+                  disabled={!hasPermission('queue:create')}
+                  title={!hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined}
                 >
                 >
                   <Clock className="w-3.5 h-3.5" />
                   <Clock className="w-3.5 h-3.5" />
                   Add to Queue
                   Add to Queue
                 </button>
                 </button>
               )}
               )}
               <button
               <button
-                className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                onClick={() => { onDownload(file.id); setShowActions(false); }}
+                className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                  hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                }`}
+                onClick={() => { if (hasPermission('library:read')) { onDownload(file.id); setShowActions(false); } }}
+                disabled={!hasPermission('library:read')}
+                title={!hasPermission('library:read') ? 'You do not have permission to download files' : undefined}
               >
               >
                 <Download className="w-3.5 h-3.5" />
                 <Download className="w-3.5 h-3.5" />
                 Download
                 Download
               </button>
               </button>
               {onRename && (
               {onRename && (
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onRename(file); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:update')) { onRename(file); setShowActions(false); } }}
+                  disabled={!hasPermission('library:update')}
+                  title={!hasPermission('library:update') ? 'You do not have permission to rename files' : undefined}
                 >
                 >
                   <Pencil className="w-3.5 h-3.5" />
                   <Pencil className="w-3.5 h-3.5" />
                   Rename
                   Rename
                 </button>
                 </button>
               )}
               )}
+              {onGenerateThumbnail && file.file_type === 'stl' && (
+                <button
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:update')) { onGenerateThumbnail(file); setShowActions(false); } }}
+                  disabled={!hasPermission('library:update')}
+                  title={!hasPermission('library:update') ? 'You do not have permission to generate thumbnails' : undefined}
+                >
+                  <Image className="w-3.5 h-3.5" />
+                  Generate Thumbnail
+                </button>
+              )}
               <button
               <button
-                className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
-                onClick={() => { onDelete(file.id); setShowActions(false); }}
+                className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                  hasPermission('library:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                }`}
+                onClick={() => { if (hasPermission('library:delete')) { onDelete(file.id); setShowActions(false); } }}
+                disabled={!hasPermission('library:delete')}
+                title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined}
               >
               >
                 <Trash2 className="w-3.5 h-3.5" />
                 <Trash2 className="w-3.5 h-3.5" />
                 Delete
                 Delete
@@ -951,7 +1034,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
       <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
       <div className={`absolute top-2 left-2 w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
         isSelected
         isSelected
           ? 'bg-bambu-green border-bambu-green'
           ? 'bg-bambu-green border-bambu-green'
-          : 'border-white/30 bg-black/30 opacity-100 md:opacity-0 md:group-hover:opacity-100'
+          : `border-white/30 bg-black/30 ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`
       }`}>
       }`}>
         {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
         {isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
       </div>
       </div>
@@ -962,6 +1045,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
 export function FileManagerPage() {
 export function FileManagerPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [searchParams] = useSearchParams();
   const [searchParams] = useSearchParams();
 
 
   // Read folder ID from URL query parameter
   // Read folder ID from URL query parameter
@@ -979,6 +1063,7 @@ export function FileManagerPage() {
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printFile, setPrintFile] = useState<LibraryFileListItem | null>(null);
   const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
   const [printMultiFile, setPrintMultiFile] = useState<LibraryFileListItem | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
   const [renameItem, setRenameItem] = useState<{ type: 'file' | 'folder'; id: number; name: string } | null>(null);
+  const [thumbnailVersions, setThumbnailVersions] = useState<Record<number, number>>({});
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
   const [viewMode, setViewMode] = useState<'grid' | 'list'>(() => {
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
     return (localStorage.getItem('library-view-mode') as 'grid' | 'list') || 'grid';
   });
   });
@@ -1032,11 +1117,20 @@ export function FileManagerPage() {
     };
     };
   }, [isResizing, sidebarWidth]);
   }, [isResizing, sidebarWidth]);
 
 
-  // Filter and sort state
+  // Filter and sort state (persist sort preferences to localStorage)
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [filterType, setFilterType] = useState<string>('all');
   const [filterType, setFilterType] = useState<string>('all');
-  const [sortField, setSortField] = useState<SortField>('date');
-  const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
+  const [sortField, setSortField] = useState<SortField>(() => {
+    const saved = localStorage.getItem('library-sort-field');
+    return (saved as SortField) || 'name';
+  });
+  const [sortDirection, setSortDirection] = useState<SortDirection>(() => {
+    const saved = localStorage.getItem('library-sort-direction');
+    return (saved as SortDirection) || 'asc';
+  });
+
+  // Mobile detection for touch-friendly UI
+  const isMobile = useIsMobile();
 
 
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
   // Update selectedFolderId when URL parameter changes (e.g., navigating from Project or Archive page)
   useEffect(() => {
   useEffect(() => {
@@ -1272,6 +1366,52 @@ export function FileManagerPage() {
     },
     },
   });
   });
 
 
+  const batchThumbnailMutation = useMutation({
+    mutationFn: () => api.batchGenerateStlThumbnails({ all_missing: true }),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      // Update thumbnail versions for cache busting
+      if (result.succeeded > 0) {
+        const now = Date.now();
+        const newVersions: Record<number, number> = {};
+        result.results.forEach((r) => {
+          if (r.success) {
+            newVersions[r.file_id] = now;
+          }
+        });
+        setThumbnailVersions((prev) => ({ ...prev, ...newVersions }));
+      }
+      if (result.succeeded > 0 && result.failed === 0) {
+        showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}`, 'success');
+      } else if (result.succeeded > 0 && result.failed > 0) {
+        showToast(`Generated ${result.succeeded} thumbnail${result.succeeded > 1 ? 's' : ''}, ${result.failed} failed`, 'success');
+      } else if (result.processed === 0) {
+        showToast('No STL files missing thumbnails', 'info');
+      } else {
+        showToast(`Failed to generate thumbnails: ${result.results[0]?.error || 'Unknown error'}`, 'error');
+      }
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const singleThumbnailMutation = useMutation({
+    mutationFn: (fileId: number) => api.batchGenerateStlThumbnails({ file_ids: [fileId] }),
+    onSuccess: (result) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      // Update thumbnail version for cache busting
+      if (result.succeeded > 0) {
+        const fileId = result.results[0]?.file_id;
+        if (fileId) {
+          setThumbnailVersions((prev) => ({ ...prev, [fileId]: Date.now() }));
+        }
+        showToast('Thumbnail generated', 'success');
+      } else {
+        showToast(`Failed to generate thumbnail: ${result.results[0]?.error || 'Unknown error'}`, 'error');
+      }
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
   // Helper to check if a file is sliced (printable)
   // Helper to check if a file is sliced (printable)
   const isSlicedFile = useCallback((filename: string) => {
   const isSlicedFile = useCallback((filename: string) => {
     const lower = filename.toLowerCase();
     const lower = filename.toLowerCase();
@@ -1333,7 +1473,7 @@ export function FileManagerPage() {
   const isLoading = foldersLoading || filesLoading;
   const isLoading = foldersLoading || filesLoading;
 
 
   return (
   return (
-    <div className="p-4 md:p-8 h-[calc(100vh-64px)] flex flex-col">
+    <div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
       {/* Header */}
       {/* Header */}
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
         <div>
         <div>
@@ -1369,11 +1509,33 @@ export function FileManagerPage() {
               <List className="w-4 h-4" />
               <List className="w-4 h-4" />
             </button>
             </button>
           </div>
           </div>
-          <Button variant="secondary" onClick={() => setShowNewFolderModal(true)}>
+          <Button
+            variant="secondary"
+            onClick={() => batchThumbnailMutation.mutate()}
+            disabled={batchThumbnailMutation.isPending || !hasPermission('library:update')}
+            title={!hasPermission('library:update') ? 'You do not have permission to generate thumbnails' : 'Generate thumbnails for STL files missing them'}
+          >
+            {batchThumbnailMutation.isPending ? (
+              <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+            ) : (
+              <Image className="w-4 h-4 mr-2" />
+            )}
+            Generate Thumbnails
+          </Button>
+          <Button
+            variant="secondary"
+            onClick={() => setShowNewFolderModal(true)}
+            disabled={!hasPermission('library:upload')}
+            title={!hasPermission('library:upload') ? 'You do not have permission to create folders' : undefined}
+          >
             <FolderPlus className="w-4 h-4 mr-2" />
             <FolderPlus className="w-4 h-4 mr-2" />
             New Folder
             New Folder
           </Button>
           </Button>
-          <Button onClick={() => setShowUploadModal(true)}>
+          <Button
+            onClick={() => setShowUploadModal(true)}
+            disabled={!hasPermission('library:upload')}
+            title={!hasPermission('library:upload') ? 'You do not have permission to upload files' : undefined}
+          >
             <Upload className="w-4 h-4 mr-2" />
             <Upload className="w-4 h-4 mr-2" />
             Upload
             Upload
           </Button>
           </Button>
@@ -1396,7 +1558,7 @@ export function FileManagerPage() {
 
 
       {/* Stats bar */}
       {/* Stats bar */}
       {stats && (
       {stats && (
-        <div className="flex items-center gap-6 mb-6 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+        <div className="flex flex-wrap items-center gap-3 sm:gap-6 mb-6 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
           <div className="flex items-center gap-2 text-sm">
           <div className="flex items-center gap-2 text-sm">
             <File className="w-4 h-4 text-bambu-green" />
             <File className="w-4 h-4 text-bambu-green" />
             <span className="text-bambu-gray">Files:</span>
             <span className="text-bambu-gray">Files:</span>
@@ -1412,7 +1574,7 @@ export function FileManagerPage() {
             <span className="text-bambu-gray">Size:</span>
             <span className="text-bambu-gray">Size:</span>
             <span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
             <span className="text-white font-medium">{formatFileSize(stats.total_size_bytes)}</span>
           </div>
           </div>
-          <div className="flex items-center gap-2 text-sm ml-auto">
+          <div className="flex items-center gap-2 text-sm sm:ml-auto">
             <span className="text-bambu-gray">Free:</span>
             <span className="text-bambu-gray">Free:</span>
             <span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
             <span className={`font-medium ${isDiskSpaceLow ? 'text-amber-500' : 'text-white'}`}>
               {formatFileSize(stats.disk_free_bytes)}
               {formatFileSize(stats.disk_free_bytes)}
@@ -1422,11 +1584,40 @@ export function FileManagerPage() {
       )}
       )}
 
 
       {/* Main content */}
       {/* Main content */}
-      <div className="flex-1 flex gap-6 min-h-0">
-        {/* Folder sidebar - resizable */}
+      <div className="flex-1 flex flex-col lg:flex-row gap-4 lg:gap-6 min-h-0">
+        {/* Mobile folder selector */}
+        <div className="lg:hidden">
+          <select
+            value={selectedFolderId ?? ''}
+            onChange={(e) => setSelectedFolderId(e.target.value ? parseInt(e.target.value, 10) : null)}
+            className="w-full bg-bambu-card border border-bambu-dark-tertiary rounded-lg px-3 py-2.5 text-white focus:outline-none focus:border-bambu-green"
+          >
+            <option value="">📁 All Files</option>
+            {folders && (() => {
+              // Flatten folder tree for mobile selector
+              const flattenFolders = (items: LibraryFolderTree[], depth = 0): { id: number; name: string; fileCount: number; depth: number }[] => {
+                const result: { id: number; name: string; fileCount: number; depth: number }[] = [];
+                for (const item of items) {
+                  result.push({ id: item.id, name: item.name, fileCount: item.file_count, depth });
+                  if (item.children.length > 0) {
+                    result.push(...flattenFolders(item.children, depth + 1));
+                  }
+                }
+                return result;
+              };
+              return flattenFolders(folders).map((folder) => (
+                <option key={folder.id} value={folder.id}>
+                  {'│ '.repeat(folder.depth)}📂 {folder.name} {folder.fileCount > 0 ? `(${folder.fileCount})` : ''}
+                </option>
+              ));
+            })()}
+          </select>
+        </div>
+
+        {/* Folder sidebar - resizable, hidden on mobile */}
         <div
         <div
           ref={sidebarRef}
           ref={sidebarRef}
-          className="flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex flex-col relative"
+          className="hidden lg:flex flex-shrink-0 bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden flex-col relative"
           style={{ width: `${sidebarWidth}px` }}
           style={{ width: `${sidebarWidth}px` }}
         >
         >
           {/* Resize handle - drag to resize, double-click to reset */}
           {/* Resize handle - drag to resize, double-click to reset */}
@@ -1494,18 +1685,19 @@ export function FileManagerPage() {
                 onLink={setLinkFolder}
                 onLink={setLinkFolder}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 wrapNames={wrapFolderNames}
                 wrapNames={wrapFolderNames}
+                hasPermission={hasPermission}
               />
               />
             ))}
             ))}
           </div>
           </div>
         </div>
         </div>
 
 
         {/* Files area */}
         {/* Files area */}
-        <div className="flex-1 flex flex-col min-w-0">
-          {/* Search, Filter, Sort toolbar */}
+        <div className="flex-1 flex flex-col min-w-0 min-h-0">
+          {/* Search, Filter, Sort toolbar - sticky on mobile for easier access */}
           {files && files.length > 0 && (
           {files && files.length > 0 && (
-            <div className="flex items-center gap-3 mb-4 p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+            <div className="flex flex-wrap items-center gap-2 sm:gap-3 mb-4 p-2 sm:p-3 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-0 z-10 lg:static">
               {/* Search */}
               {/* Search */}
-              <div className="relative flex-1 max-w-xs">
+              <div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-xs">
                 <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
                 <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
                 <input
                 <input
                   type="text"
                   type="text"
@@ -1518,7 +1710,7 @@ export function FileManagerPage() {
 
 
               {/* Type filter */}
               {/* Type filter */}
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
-                <Filter className="w-4 h-4 text-bambu-gray" />
+                <Filter className="w-4 h-4 text-bambu-gray hidden sm:block" />
                 <select
                 <select
                   value={filterType}
                   value={filterType}
                   onChange={(e) => setFilterType(e.target.value)}
                   onChange={(e) => setFilterType(e.target.value)}
@@ -1537,17 +1729,25 @@ export function FileManagerPage() {
               <div className="flex items-center gap-2">
               <div className="flex items-center gap-2">
                 <select
                 <select
                   value={sortField}
                   value={sortField}
-                  onChange={(e) => setSortField(e.target.value as SortField)}
+                  onChange={(e) => {
+                    const newField = e.target.value as SortField;
+                    setSortField(newField);
+                    localStorage.setItem('library-sort-field', newField);
+                  }}
                   className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
                   className="bg-bambu-dark border border-bambu-dark-tertiary rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-bambu-green"
                 >
                 >
-                  <option value="date">Date</option>
                   <option value="name">Name</option>
                   <option value="name">Name</option>
+                  <option value="date">Date</option>
                   <option value="size">Size</option>
                   <option value="size">Size</option>
                   <option value="type">Type</option>
                   <option value="type">Type</option>
                   <option value="prints">Prints</option>
                   <option value="prints">Prints</option>
                 </select>
                 </select>
                 <button
                 <button
-                  onClick={() => setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'))}
+                  onClick={() => setSortDirection((d) => {
+                    const newDir = d === 'asc' ? 'desc' : 'asc';
+                    localStorage.setItem('library-sort-direction', newDir);
+                    return newDir;
+                  })}
                   className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
                   className="p-1.5 rounded bg-bambu-dark border border-bambu-dark-tertiary hover:border-bambu-green transition-colors"
                   title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
                   title={sortDirection === 'asc' ? 'Ascending' : 'Descending'}
                 >
                 >
@@ -1561,16 +1761,16 @@ export function FileManagerPage() {
 
 
               {/* Results count */}
               {/* Results count */}
               {(searchQuery || filterType !== 'all') && (
               {(searchQuery || filterType !== 'all') && (
-                <span className="text-sm text-bambu-gray">
+                <span className="text-sm text-bambu-gray hidden sm:inline">
                   {filteredAndSortedFiles.length} of {files.length} files
                   {filteredAndSortedFiles.length} of {files.length} files
                 </span>
                 </span>
               )}
               )}
             </div>
             </div>
           )}
           )}
 
 
-          {/* Selection toolbar */}
+          {/* Selection toolbar - sticky on mobile below search bar */}
           {filteredAndSortedFiles.length > 0 && (
           {filteredAndSortedFiles.length > 0 && (
-            <div className="flex items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary">
+            <div className="flex flex-wrap items-center gap-2 mb-4 p-2 bg-bambu-card rounded-lg border border-bambu-dark-tertiary sticky top-[52px] z-10 lg:static">
               {/* Select all / Deselect all */}
               {/* Select all / Deselect all */}
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
               {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
                 <Button
                 <Button
@@ -1578,8 +1778,8 @@ export function FileManagerPage() {
                   size="sm"
                   size="sm"
                   onClick={handleDeselectAll}
                   onClick={handleDeselectAll}
                 >
                 >
-                  <Square className="w-4 h-4 mr-1" />
-                  Deselect All
+                  <Square className="w-4 h-4 sm:mr-1" />
+                  <span className="hidden sm:inline">Deselect All</span>
                 </Button>
                 </Button>
               ) : (
               ) : (
                 <Button
                 <Button
@@ -1587,8 +1787,8 @@ export function FileManagerPage() {
                   size="sm"
                   size="sm"
                   onClick={handleSelectAll}
                   onClick={handleSelectAll}
                 >
                 >
-                  <CheckSquare className="w-4 h-4 mr-1" />
-                  Select All
+                  <CheckSquare className="w-4 h-4 sm:mr-1" />
+                  <span className="hidden sm:inline">Select All</span>
                 </Button>
                 </Button>
               )}
               )}
 
 
@@ -1597,57 +1797,67 @@ export function FileManagerPage() {
                   <span className="text-sm text-bambu-gray ml-2">
                   <span className="text-sm text-bambu-gray ml-2">
                     {selectedFiles.length} selected
                     {selectedFiles.length} selected
                   </span>
                   </span>
-                  <div className="flex-1" />
-                  {selectedSlicedFiles.length === 1 && (
+                  <div className="hidden sm:block flex-1" />
+                  <div className="w-full sm:w-auto flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
+                    {selectedSlicedFiles.length === 1 && (
+                      <Button
+                        variant="primary"
+                        size="sm"
+                        onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
+                        disabled={!hasPermission('printers:control')}
+                        title={!hasPermission('printers:control') ? 'You do not have permission to print' : undefined}
+                      >
+                        <Play className="w-4 h-4 sm:mr-1" />
+                        <span className="hidden sm:inline">Print</span>
+                      </Button>
+                    )}
+                    {selectedSlicedFiles.length > 0 && (
+                      <Button
+                        variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
+                        size="sm"
+                        onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
+                        disabled={addToQueueMutation.isPending || !hasPermission('queue:create')}
+                        title={!hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined}
+                      >
+                        <Clock className="w-4 h-4 sm:mr-1" />
+                        <span className="hidden sm:inline">{addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}</span>
+                      </Button>
+                    )}
                     <Button
                     <Button
-                      variant="primary"
+                      variant="secondary"
                       size="sm"
                       size="sm"
-                      onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
+                      onClick={() => setShowMoveModal(true)}
+                      disabled={!hasPermission('library:update')}
+                      title={!hasPermission('library:update') ? 'You do not have permission to move files' : undefined}
                     >
                     >
-                      <Play className="w-4 h-4 mr-1" />
-                      Print
+                      <MoveRight className="w-4 h-4 sm:mr-1" />
+                      <span className="hidden sm:inline">Move</span>
                     </Button>
                     </Button>
-                  )}
-                  {selectedSlicedFiles.length > 0 && (
                     <Button
                     <Button
-                      variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
+                      variant="danger"
                       size="sm"
                       size="sm"
-                      onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
-                      disabled={addToQueueMutation.isPending}
+                      onClick={() => {
+                        if (selectedFiles.length === 1) {
+                          setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
+                        } else {
+                          setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
+                        }
+                      }}
+                      disabled={!hasPermission('library:delete')}
+                      title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined}
                     >
                     >
-                      <Clock className="w-4 h-4 mr-1" />
-                      {addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}
+                      <Trash2 className="w-4 h-4 sm:mr-1" />
+                      <span className="hidden sm:inline">Delete</span>
                     </Button>
                     </Button>
-                  )}
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={() => setShowMoveModal(true)}
-                  >
-                    <MoveRight className="w-4 h-4 mr-1" />
-                    Move
-                  </Button>
-                  <Button
-                    variant="danger"
-                    size="sm"
-                    onClick={() => {
-                      if (selectedFiles.length === 1) {
-                        setDeleteConfirm({ type: 'file', id: selectedFiles[0] });
-                      } else {
-                        setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
-                      }
-                    }}
-                  >
-                    <Trash2 className="w-4 h-4 mr-1" />
-                    Delete
-                  </Button>
-                  <Button
-                    variant="secondary"
-                    size="sm"
-                    onClick={handleDeselectAll}
-                  >
-                    Clear
-                  </Button>
+                    <Button
+                      variant="secondary"
+                      size="sm"
+                      onClick={handleDeselectAll}
+                    >
+                      <X className="w-4 h-4 sm:mr-1" />
+                      <span className="hidden sm:inline">Clear</span>
+                    </Button>
+                  </div>
                 </>
                 </>
               )}
               )}
             </div>
             </div>
@@ -1674,7 +1884,11 @@ export function FileManagerPage() {
                   ? 'Upload files or move files into this folder to get started.'
                   ? 'Upload files or move files into this folder to get started.'
                   : 'Upload files to start organizing your print-related files.'}
                   : 'Upload files to start organizing your print-related files.'}
               </p>
               </p>
-              <Button onClick={() => setShowUploadModal(true)}>
+              <Button
+                onClick={() => setShowUploadModal(true)}
+                disabled={!hasPermission('library:upload')}
+                title={!hasPermission('library:upload') ? 'You do not have permission to upload files' : undefined}
+              >
                 <Plus className="w-4 h-4 mr-2" />
                 <Plus className="w-4 h-4 mr-2" />
                 Upload Files
                 Upload Files
               </Button>
               </Button>
@@ -1693,28 +1907,32 @@ export function FileManagerPage() {
               </Button>
               </Button>
             </div>
             </div>
           ) : viewMode === 'grid' ? (
           ) : viewMode === 'grid' ? (
-            <div className="flex-1 overflow-y-auto">
+            <div className="flex-1 lg:overflow-y-auto">
               <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
               <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
                 {filteredAndSortedFiles.map((file) => (
                 {filteredAndSortedFiles.map((file) => (
                   <FileCard
                   <FileCard
                     key={file.id}
                     key={file.id}
                     file={file}
                     file={file}
                     isSelected={selectedFiles.includes(file.id)}
                     isSelected={selectedFiles.includes(file.id)}
+                    isMobile={isMobile}
                     onSelect={handleFileSelect}
                     onSelect={handleFileSelect}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDelete={(id) => setDeleteConfirm({ type: 'file', id })}
                     onDownload={handleDownload}
                     onDownload={handleDownload}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onAddToQueue={(id) => addToQueueMutation.mutate([id])}
                     onPrint={setPrintFile}
                     onPrint={setPrintFile}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
+                    onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
+                    thumbnailVersion={thumbnailVersions[file.id]}
+                    hasPermission={hasPermission}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
             </div>
             </div>
           ) : (
           ) : (
-            <div className="flex-1 overflow-y-auto">
+            <div className="flex-1 lg:overflow-y-auto">
               <div className="bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
               <div className="bg-bambu-card rounded-lg border border-bambu-dark-tertiary overflow-hidden">
-                {/* List header */}
-                <div className="grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
+                {/* List header - hidden on mobile, show simplified on small screens */}
+                <div className="hidden sm:grid grid-cols-[auto_1fr_100px_100px_100px_80px] gap-4 px-4 py-2 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary text-xs text-bambu-gray font-medium">
                   <div className="w-6" />
                   <div className="w-6" />
                   <div>Name</div>
                   <div>Name</div>
                   <div>Type</div>
                   <div>Type</div>
@@ -1745,7 +1963,7 @@ export function FileManagerPage() {
                         <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
                         <div className="w-10 h-10 rounded bg-bambu-dark flex-shrink-0 overflow-hidden">
                           {file.thumbnail_path ? (
                           {file.thumbnail_path ? (
                             <img
                             <img
-                              src={api.getLibraryFileThumbnailUrl(file.id)}
+                              src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
                               alt=""
                               alt=""
                               className="w-full h-full object-cover"
                               className="w-full h-full object-cover"
                             />
                             />
@@ -1760,7 +1978,7 @@ export function FileManagerPage() {
                           <div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
                           <div className="absolute left-0 top-full mt-2 z-50 hidden group-hover/thumb:block">
                             <div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
                             <div className="w-48 h-48 rounded-lg bg-bambu-dark-secondary border border-bambu-dark-tertiary shadow-xl overflow-hidden">
                               <img
                               <img
-                                src={api.getLibraryFileThumbnailUrl(file.id)}
+                                src={`${api.getLibraryFileThumbnailUrl(file.id)}${thumbnailVersions[file.id] ? `?v=${thumbnailVersions[file.id]}` : ''}`}
                                 alt={file.filename}
                                 alt={file.filename}
                                 className="w-full h-full object-contain"
                                 className="w-full h-full object-contain"
                               />
                               />
@@ -1792,40 +2010,78 @@ export function FileManagerPage() {
                       {isSlicedFilename(file.filename) && (
                       {isSlicedFilename(file.filename) && (
                         <>
                         <>
                           <button
                           <button
-                            onClick={() => setPrintFile(file)}
-                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
-                            title="Print"
+                            onClick={() => hasPermission('printers:control') && setPrintFile(file)}
+                            className={`p-1.5 rounded transition-colors ${
+                              hasPermission('printers:control')
+                                ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                                : 'text-bambu-gray/50 cursor-not-allowed'
+                            }`}
+                            title={hasPermission('printers:control') ? 'Print' : 'You do not have permission to print'}
+                            disabled={!hasPermission('printers:control')}
                           >
                           >
                             <Printer className="w-4 h-4" />
                             <Printer className="w-4 h-4" />
                           </button>
                           </button>
                           <button
                           <button
-                            onClick={() => addToQueueMutation.mutate([file.id])}
-                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                            title="Add to Queue"
-                            disabled={addToQueueMutation.isPending}
+                            onClick={() => hasPermission('queue:create') && addToQueueMutation.mutate([file.id])}
+                            className={`p-1.5 rounded transition-colors ${
+                              hasPermission('queue:create')
+                                ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
+                                : 'text-bambu-gray/50 cursor-not-allowed'
+                            }`}
+                            title={hasPermission('queue:create') ? 'Add to Queue' : 'You do not have permission to add to queue'}
+                            disabled={addToQueueMutation.isPending || !hasPermission('queue:create')}
                           >
                           >
                             <Clock className="w-4 h-4" />
                             <Clock className="w-4 h-4" />
                           </button>
                           </button>
                         </>
                         </>
                       )}
                       )}
                       <button
                       <button
-                        onClick={() => handleDownload(file.id)}
-                        className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                        title="Download"
+                        onClick={() => hasPermission('library:read') && handleDownload(file.id)}
+                        className={`p-1.5 rounded transition-colors ${
+                          hasPermission('library:read')
+                            ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
+                            : 'text-bambu-gray/50 cursor-not-allowed'
+                        }`}
+                        title={hasPermission('library:read') ? 'Download' : 'You do not have permission to download files'}
+                        disabled={!hasPermission('library:read')}
                       >
                       >
                         <Download className="w-4 h-4" />
                         <Download className="w-4 h-4" />
                       </button>
                       </button>
                       <button
                       <button
-                        onClick={() => setRenameItem({ type: 'file', id: file.id, name: file.filename })}
-                        className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                        title="Rename"
+                        onClick={() => hasPermission('library:update') && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
+                        className={`p-1.5 rounded transition-colors ${
+                          hasPermission('library:update')
+                            ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
+                            : 'text-bambu-gray/50 cursor-not-allowed'
+                        }`}
+                        title={hasPermission('library:update') ? 'Rename' : 'You do not have permission to rename files'}
+                        disabled={!hasPermission('library:update')}
                       >
                       >
                         <Pencil className="w-4 h-4" />
                         <Pencil className="w-4 h-4" />
                       </button>
                       </button>
+                      {file.file_type === 'stl' && (
+                        <button
+                          onClick={() => hasPermission('library:update') && singleThumbnailMutation.mutate(file.id)}
+                          className={`p-1.5 rounded transition-colors ${
+                            hasPermission('library:update')
+                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                              : 'text-bambu-gray/50 cursor-not-allowed'
+                          }`}
+                          title={hasPermission('library:update') ? 'Generate Thumbnail' : 'You do not have permission to generate thumbnails'}
+                          disabled={singleThumbnailMutation.isPending || !hasPermission('library:update')}
+                        >
+                          <Image className="w-4 h-4" />
+                        </button>
+                      )}
                       <button
                       <button
-                        onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
-                        className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
-                        title="Delete"
+                        onClick={() => hasPermission('library:delete') && setDeleteConfirm({ type: 'file', id: file.id })}
+                        className={`p-1.5 rounded transition-colors ${
+                          hasPermission('library:delete')
+                            ? 'hover:bg-bambu-dark text-bambu-gray hover:text-red-400'
+                            : 'text-bambu-gray/50 cursor-not-allowed'
+                        }`}
+                        title={hasPermission('library:delete') ? 'Delete' : 'You do not have permission to delete files'}
+                        disabled={!hasPermission('library:delete')}
                       >
                       >
                         <Trash2 className="w-4 h-4" />
                         <Trash2 className="w-4 h-4" />
                       </button>
                       </button>

+ 496 - 0
frontend/src/pages/GroupsPage.tsx

@@ -0,0 +1,496 @@
+import { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+  X,
+  Plus,
+  Edit2,
+  Trash2,
+  Save,
+  Loader2,
+  Shield,
+  ArrowLeft,
+  Users,
+  Check,
+  ChevronDown,
+  ChevronRight,
+} from 'lucide-react';
+import { api } from '../api/client';
+import type { Group, GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { Button } from '../components/Button';
+import { Card, CardContent, CardHeader } from '../components/Card';
+import { ConfirmModal } from '../components/ConfirmModal';
+
+export function GroupsPage() {
+  const navigate = useNavigate();
+  const { hasPermission } = useAuth();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+
+  const [showCreateModal, setShowCreateModal] = useState(false);
+  const [editingGroup, setEditingGroup] = useState<Group | null>(null);
+  const [deleteGroupId, setDeleteGroupId] = useState<number | null>(null);
+  const [formData, setFormData] = useState<{
+    name: string;
+    description: string;
+    permissions: Permission[];
+  }>({
+    name: '',
+    description: '',
+    permissions: [],
+  });
+  const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
+
+  // Close modal on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && (showCreateModal || editingGroup)) {
+        setShowCreateModal(false);
+        setEditingGroup(null);
+        resetForm();
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [showCreateModal, editingGroup]);
+
+  const { data: groups = [], isLoading: groupsLoading } = useQuery({
+    queryKey: ['groups'],
+    queryFn: () => api.getGroups(),
+    enabled: hasPermission('groups:read'),
+  });
+
+  const { data: permissionsData } = useQuery({
+    queryKey: ['permissions'],
+    queryFn: () => api.getPermissions(),
+    enabled: hasPermission('groups:read'),
+  });
+
+  const createMutation = useMutation({
+    mutationFn: (data: GroupCreate) => api.createGroup(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['groups'] });
+      setShowCreateModal(false);
+      resetForm();
+      showToast('Group created successfully');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: ({ id, data }: { id: number; data: GroupUpdate }) => api.updateGroup(id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['groups'] });
+      setEditingGroup(null);
+      resetForm();
+      showToast('Group updated successfully');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteGroup(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['groups'] });
+      showToast('Group deleted successfully');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const resetForm = () => {
+    setFormData({ name: '', description: '', permissions: [] });
+    setExpandedCategories(new Set());
+  };
+
+  const handleCreate = () => {
+    if (!formData.name.trim()) {
+      showToast('Please enter a group name', 'error');
+      return;
+    }
+    createMutation.mutate({
+      name: formData.name,
+      description: formData.description || undefined,
+      permissions: formData.permissions,
+    });
+  };
+
+  const handleUpdate = () => {
+    if (!editingGroup) return;
+    if (!formData.name.trim()) {
+      showToast('Please enter a group name', 'error');
+      return;
+    }
+    updateMutation.mutate({
+      id: editingGroup.id,
+      data: {
+        name: formData.name !== editingGroup.name ? formData.name : undefined,
+        description: formData.description,
+        permissions: formData.permissions,
+      },
+    });
+  };
+
+  const handleDelete = (id: number) => {
+    setDeleteGroupId(id);
+  };
+
+  const startEdit = (group: Group) => {
+    setEditingGroup(group);
+    setFormData({
+      name: group.name,
+      description: group.description || '',
+      permissions: group.permissions,
+    });
+    // Expand categories that have selected permissions
+    const cats = new Set<string>();
+    permissionsData?.categories.forEach((cat) => {
+      if (cat.permissions.some((p) => group.permissions.includes(p.value))) {
+        cats.add(cat.name);
+      }
+    });
+    setExpandedCategories(cats);
+  };
+
+  const toggleCategory = (categoryName: string) => {
+    setExpandedCategories((prev) => {
+      const next = new Set(prev);
+      if (next.has(categoryName)) {
+        next.delete(categoryName);
+      } else {
+        next.add(categoryName);
+      }
+      return next;
+    });
+  };
+
+  const togglePermission = (permission: Permission) => {
+    setFormData((prev) => {
+      const permissions = prev.permissions.includes(permission)
+        ? prev.permissions.filter((p) => p !== permission)
+        : [...prev.permissions, permission];
+      return { ...prev, permissions };
+    });
+  };
+
+  const toggleCategoryPermissions = (category: PermissionCategory, checked: boolean) => {
+    setFormData((prev) => {
+      const categoryPerms = category.permissions.map((p) => p.value);
+      const otherPerms = prev.permissions.filter((p) => !categoryPerms.includes(p));
+      const permissions = checked ? [...otherPerms, ...categoryPerms] : otherPerms;
+      return { ...prev, permissions };
+    });
+  };
+
+  const isCategoryFullySelected = (category: PermissionCategory) => {
+    return category.permissions.every((p) => formData.permissions.includes(p.value));
+  };
+
+  const isCategoryPartiallySelected = (category: PermissionCategory) => {
+    const selected = category.permissions.filter((p) => formData.permissions.includes(p.value));
+    return selected.length > 0 && selected.length < category.permissions.length;
+  };
+
+  // Permission check
+  if (!hasPermission('groups:read')) {
+    return (
+      <div className="p-6">
+        <Card>
+          <CardContent className="py-6">
+            <div className="flex items-center gap-3 text-red-400">
+              <Shield className="w-5 h-5" />
+              <p className="text-white">You do not have permission to access this page.</p>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
+
+  const renderPermissionEditor = () => (
+    <div className="space-y-2 max-h-96 overflow-y-auto">
+      {permissionsData?.categories.map((category) => (
+        <div key={category.name} className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+          <div
+            className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary cursor-pointer hover:bg-bambu-dark-tertiary transition-colors"
+            onClick={() => toggleCategory(category.name)}
+          >
+            <div className="flex items-center gap-3">
+              <button
+                type="button"
+                onClick={(e) => {
+                  e.stopPropagation();
+                  toggleCategoryPermissions(category, !isCategoryFullySelected(category));
+                }}
+                className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${
+                  isCategoryFullySelected(category)
+                    ? 'bg-bambu-green border-bambu-green'
+                    : isCategoryPartiallySelected(category)
+                    ? 'bg-bambu-green/50 border-bambu-green'
+                    : 'border-bambu-gray hover:border-white'
+                }`}
+              >
+                {(isCategoryFullySelected(category) || isCategoryPartiallySelected(category)) && (
+                  <Check className="w-3 h-3 text-white" />
+                )}
+              </button>
+              <span className="text-white font-medium">{category.name}</span>
+              <span className="text-xs text-bambu-gray">
+                ({category.permissions.filter((p) => formData.permissions.includes(p.value)).length}/
+                {category.permissions.length})
+              </span>
+            </div>
+            {expandedCategories.has(category.name) ? (
+              <ChevronDown className="w-4 h-4 text-bambu-gray" />
+            ) : (
+              <ChevronRight className="w-4 h-4 text-bambu-gray" />
+            )}
+          </div>
+          {expandedCategories.has(category.name) && (
+            <div className="p-3 bg-bambu-dark space-y-2">
+              {category.permissions.map((perm) => (
+                <label
+                  key={perm.value}
+                  className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer"
+                >
+                  <input
+                    type="checkbox"
+                    checked={formData.permissions.includes(perm.value)}
+                    onChange={() => togglePermission(perm.value)}
+                    className="w-4 h-4 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">{perm.label}</span>
+                </label>
+              ))}
+            </div>
+          )}
+        </div>
+      ))}
+    </div>
+  );
+
+  return (
+    <div className="p-6">
+      <div className="flex justify-between items-center mb-6">
+        <div className="flex items-center gap-4">
+          <button
+            onClick={() => navigate('/settings?tab=users')}
+            className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+            title="Back to Settings"
+          >
+            <ArrowLeft className="w-5 h-5" />
+          </button>
+          <div>
+            <h1 className="text-2xl font-bold text-white flex items-center gap-2">
+              <Shield className="w-6 h-6 text-bambu-green" />
+              Group Management
+            </h1>
+            <p className="text-sm text-bambu-gray mt-1">
+              Manage permission groups for access control
+            </p>
+          </div>
+        </div>
+        {hasPermission('groups:create') && (
+          <Button
+            onClick={() => {
+              setShowCreateModal(true);
+              resetForm();
+            }}
+          >
+            <Plus className="w-4 h-4" />
+            Create Group
+          </Button>
+        )}
+      </div>
+
+      {groupsLoading ? (
+        <div className="flex items-center justify-center py-12">
+          <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+        </div>
+      ) : (
+        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+          {groups.map((group) => (
+            <Card key={group.id}>
+              <CardHeader>
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-2">
+                    <Shield
+                      className={`w-5 h-5 ${
+                        group.name === 'Administrators'
+                          ? 'text-purple-400'
+                          : group.name === 'Operators'
+                          ? 'text-blue-400'
+                          : group.name === 'Viewers'
+                          ? 'text-green-400'
+                          : 'text-bambu-gray'
+                      }`}
+                    />
+                    <h3 className="text-lg font-semibold text-white">{group.name}</h3>
+                    {group.is_system && (
+                      <span className="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
+                        System
+                      </span>
+                    )}
+                  </div>
+                </div>
+              </CardHeader>
+              <CardContent>
+                <p className="text-sm text-bambu-gray mb-4">{group.description || 'No description'}</p>
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-2 text-sm text-bambu-gray">
+                    <Users className="w-4 h-4" />
+                    <span>{group.user_count} users</span>
+                  </div>
+                  <div className="text-xs text-bambu-gray">
+                    {group.permissions.length} permissions
+                  </div>
+                </div>
+                <div className="flex gap-2 mt-4 pt-4 border-t border-bambu-dark-tertiary">
+                  {hasPermission('groups:update') && (
+                    <Button size="sm" variant="ghost" onClick={() => startEdit(group)}>
+                      <Edit2 className="w-4 h-4" />
+                      Edit
+                    </Button>
+                  )}
+                  {hasPermission('groups:delete') && !group.is_system && (
+                    <Button size="sm" variant="ghost" onClick={() => handleDelete(group.id)}>
+                      <Trash2 className="w-4 h-4" />
+                      Delete
+                    </Button>
+                  )}
+                </div>
+              </CardContent>
+            </Card>
+          ))}
+        </div>
+      )}
+
+      {/* Create/Edit Group Modal */}
+      {(showCreateModal || editingGroup) && (
+        <div
+          className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+          onClick={() => {
+            setShowCreateModal(false);
+            setEditingGroup(null);
+            resetForm();
+          }}
+        >
+          <Card
+            className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
+            onClick={(e: React.MouseEvent) => e.stopPropagation()}
+          >
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Shield className="w-5 h-5 text-bambu-green" />
+                  <h2 className="text-lg font-semibold text-white">
+                    {editingGroup ? 'Edit Group' : 'Create Group'}
+                  </h2>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => {
+                    setShowCreateModal(false);
+                    setEditingGroup(null);
+                    resetForm();
+                  }}
+                >
+                  <X className="w-5 h-5" />
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div className="space-y-4">
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Group Name
+                  </label>
+                  <input
+                    type="text"
+                    value={formData.name}
+                    onChange={(e) => setFormData({ ...formData, name: e.target.value })}
+                    disabled={editingGroup?.is_system}
+                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50"
+                    placeholder="Enter group name"
+                  />
+                  {editingGroup?.is_system && (
+                    <p className="text-xs text-yellow-400 mt-1">System group names cannot be changed</p>
+                  )}
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Description
+                  </label>
+                  <textarea
+                    value={formData.description}
+                    onChange={(e) => setFormData({ ...formData, description: e.target.value })}
+                    rows={2}
+                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors resize-none"
+                    placeholder="Enter description (optional)"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Permissions ({formData.permissions.length} selected)
+                  </label>
+                  {renderPermissionEditor()}
+                </div>
+              </div>
+              <div className="mt-6 flex justify-end gap-3">
+                <Button
+                  variant="secondary"
+                  onClick={() => {
+                    setShowCreateModal(false);
+                    setEditingGroup(null);
+                    resetForm();
+                  }}
+                >
+                  Cancel
+                </Button>
+                <Button
+                  onClick={editingGroup ? handleUpdate : handleCreate}
+                  disabled={createMutation.isPending || updateMutation.isPending || !formData.name.trim()}
+                >
+                  {(createMutation.isPending || updateMutation.isPending) ? (
+                    <>
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      {editingGroup ? 'Saving...' : 'Creating...'}
+                    </>
+                  ) : (
+                    <>
+                      <Save className="w-4 h-4" />
+                      {editingGroup ? 'Save Changes' : 'Create Group'}
+                    </>
+                  )}
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+
+      {/* Delete Confirmation Modal */}
+      {deleteGroupId !== null && (
+        <ConfirmModal
+          title="Delete Group"
+          message="Are you sure you want to delete this group? Users in this group will lose these permissions."
+          confirmText="Delete Group"
+          variant="danger"
+          onConfirm={() => {
+            deleteMutation.mutate(deleteGroupId);
+            setDeleteGroupId(null);
+          }}
+          onCancel={() => setDeleteGroupId(null)}
+        />
+      )}
+    </div>
+  );
+}

+ 61 - 0
frontend/src/pages/LoginPage.tsx

@@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
+import { HelpCircle, X } from 'lucide-react';
 
 
 export function LoginPage() {
 export function LoginPage() {
   const navigate = useNavigate();
   const navigate = useNavigate();
@@ -12,6 +13,7 @@ export function LoginPage() {
   const { mode } = useTheme();
   const { mode } = useTheme();
   const [username, setUsername] = useState('');
   const [username, setUsername] = useState('');
   const [password, setPassword] = useState('');
   const [password, setPassword] = useState('');
+  const [showForgotPassword, setShowForgotPassword] = useState(false);
 
 
   const loginMutation = useMutation({
   const loginMutation = useMutation({
     mutationFn: () => login(username, password),
     mutationFn: () => login(username, password),
@@ -96,8 +98,67 @@ export function LoginPage() {
               {loginMutation.isPending ? 'Logging in...' : 'Sign in'}
               {loginMutation.isPending ? 'Logging in...' : 'Sign in'}
             </button>
             </button>
           </div>
           </div>
+
+          <div className="text-center">
+            <button
+              type="button"
+              onClick={() => setShowForgotPassword(true)}
+              className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
+            >
+              Forgot your password?
+            </button>
+          </div>
         </form>
         </form>
       </div>
       </div>
+
+      {/* Forgot Password Modal */}
+      {showForgotPassword && (
+        <div
+          className="fixed inset-0 bg-black flex items-center justify-center z-50 p-4"
+          onClick={() => setShowForgotPassword(false)}
+        >
+          <div
+            className="w-full max-w-md bg-bambu-card rounded-xl border border-bambu-dark-tertiary shadow-lg p-6"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <div className="flex items-center justify-between mb-4">
+              <div className="flex items-center gap-2">
+                <HelpCircle className="w-5 h-5 text-bambu-green" />
+                <h2 className="text-lg font-semibold text-white">Forgot Password</h2>
+              </div>
+              <button
+                onClick={() => setShowForgotPassword(false)}
+                className="p-1 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            </div>
+
+            <div className="space-y-4">
+              <p className="text-bambu-gray">
+                If you've forgotten your password, please contact your system administrator to reset it.
+              </p>
+
+              <div className="bg-bambu-dark rounded-lg p-4 space-y-2">
+                <p className="text-sm text-white font-medium">How to reset your password:</p>
+                <ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
+                  <li>Contact your Bambuddy administrator</li>
+                  <li>Ask them to reset your password in User Management</li>
+                  <li>They can set a new temporary password for you</li>
+                  <li>Log in with the new password and change it in Settings</li>
+                </ol>
+              </div>
+
+              <button
+                onClick={() => setShowForgotPassword(false)}
+                className="w-full py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
+              >
+                Got it
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 47 - 16
frontend/src/pages/MaintenancePage.tsx

@@ -36,11 +36,12 @@ import {
   ExternalLink,
   ExternalLink,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
+import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { Toggle } from '../components/Toggle';
 import { Toggle } from '../components/Toggle';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 
 // Icon mapping for maintenance types
 // Icon mapping for maintenance types
 const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
 const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -199,10 +200,12 @@ function MaintenanceCard({
   item,
   item,
   onPerform,
   onPerform,
   onToggle,
   onToggle,
+  hasPermission,
 }: {
 }: {
   item: MaintenanceStatus;
   item: MaintenanceStatus;
   onPerform: (id: number) => void;
   onPerform: (id: number) => void;
   onToggle: (id: number, enabled: boolean) => void;
   onToggle: (id: number, enabled: boolean) => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
   const Icon = getIcon(item.maintenance_type_icon);
   const Icon = getIcon(item.maintenance_type_icon);
   const intervalType = item.interval_type || 'hours';
   const intervalType = item.interval_type || 'hours';
@@ -324,15 +327,19 @@ function MaintenanceCard({
 
 
         {/* Actions */}
         {/* Actions */}
         <div className="flex items-center gap-2 shrink-0">
         <div className="flex items-center gap-2 shrink-0">
-          <Toggle
-            checked={item.enabled}
-            onChange={(checked) => onToggle(item.id, checked)}
-          />
+          <span title={!hasPermission('maintenance:update') ? 'You do not have permission to update maintenance items' : undefined}>
+            <Toggle
+              checked={item.enabled}
+              onChange={(checked) => onToggle(item.id, checked)}
+              disabled={!hasPermission('maintenance:update')}
+            />
+          </span>
           <Button
           <Button
             size="sm"
             size="sm"
             variant={item.is_due ? 'primary' : 'secondary'}
             variant={item.is_due ? 'primary' : 'secondary'}
             onClick={() => onPerform(item.id)}
             onClick={() => onPerform(item.id)}
-            disabled={!item.enabled}
+            disabled={!item.enabled || !hasPermission('maintenance:update')}
+            title={!hasPermission('maintenance:update') ? 'You do not have permission to perform maintenance' : undefined}
             className="!px-3"
             className="!px-3"
           >
           >
             <RotateCcw className="w-3.5 h-3.5" />
             <RotateCcw className="w-3.5 h-3.5" />
@@ -350,11 +357,13 @@ function PrinterSection({
   onPerform,
   onPerform,
   onToggle,
   onToggle,
   onSetHours,
   onSetHours,
+  hasPermission,
 }: {
 }: {
   overview: PrinterMaintenanceOverview;
   overview: PrinterMaintenanceOverview;
   onPerform: (id: number) => void;
   onPerform: (id: number) => void;
   onToggle: (id: number, enabled: boolean) => void;
   onToggle: (id: number, enabled: boolean) => void;
   onSetHours: (printerId: number, hours: number) => void;
   onSetHours: (printerId: number, hours: number) => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
   const [expanded, setExpanded] = useState(true);
   const [expanded, setExpanded] = useState(true);
   const [editingHours, setEditingHours] = useState(false);
   const [editingHours, setEditingHours] = useState(false);
@@ -445,14 +454,16 @@ function PrinterSection({
             ) : (
             ) : (
               <button
               <button
                 onClick={() => {
                 onClick={() => {
+                  if (!hasPermission('maintenance:update')) return;
                   setHoursInput(Math.round(overview.total_print_hours).toString());
                   setHoursInput(Math.round(overview.total_print_hours).toString());
                   setEditingHours(true);
                   setEditingHours(true);
                 }}
                 }}
-                className="group"
+                className={`group ${!hasPermission('maintenance:update') ? 'cursor-not-allowed opacity-60' : ''}`}
+                title={!hasPermission('maintenance:update') ? 'You do not have permission to edit print hours' : undefined}
               >
               >
-                <div className="text-sm font-medium text-white group-hover:text-bambu-green transition-colors flex items-center gap-1">
+                <div className={`text-sm font-medium text-white ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''} transition-colors flex items-center gap-1`}>
                   {Math.round(overview.total_print_hours)} hours
                   {Math.round(overview.total_print_hours)} hours
-                  <Edit3 className="w-3 h-3 text-bambu-gray group-hover:text-bambu-green" />
+                  <Edit3 className={`w-3 h-3 text-bambu-gray ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''}`} />
                 </div>
                 </div>
                 <div className="text-xs text-bambu-gray">Total print time</div>
                 <div className="text-xs text-bambu-gray">Total print time</div>
               </button>
               </button>
@@ -496,6 +507,7 @@ function PrinterSection({
                 item={item}
                 item={item}
                 onPerform={onPerform}
                 onPerform={onPerform}
                 onToggle={onToggle}
                 onToggle={onToggle}
+                hasPermission={hasPermission}
               />
               />
             ))}
             ))}
           </div>
           </div>
@@ -515,6 +527,7 @@ function SettingsSection({
   onDeleteType,
   onDeleteType,
   onAssignType,
   onAssignType,
   onRemoveItem,
   onRemoveItem,
+  hasPermission,
 }: {
 }: {
   overview: PrinterMaintenanceOverview[] | undefined;
   overview: PrinterMaintenanceOverview[] | undefined;
   types: MaintenanceType[];
   types: MaintenanceType[];
@@ -524,6 +537,7 @@ function SettingsSection({
   onDeleteType: (id: number) => void;
   onDeleteType: (id: number) => void;
   onAssignType: (printerId: number, typeId: number) => void;
   onAssignType: (printerId: number, typeId: number) => void;
   onRemoveItem: (itemId: number) => void;
   onRemoveItem: (itemId: number) => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
   const [editingInterval, setEditingInterval] = useState<number | null>(null);
   const [editingInterval, setEditingInterval] = useState<number | null>(null);
   const [intervalInput, setIntervalInput] = useState('');
   const [intervalInput, setIntervalInput] = useState('');
@@ -654,7 +668,11 @@ function SettingsSection({
             <h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
             <h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
             <p className="text-sm text-bambu-gray mt-1">System types and your custom maintenance tasks</p>
             <p className="text-sm text-bambu-gray mt-1">System types and your custom maintenance tasks</p>
           </div>
           </div>
-          <Button onClick={() => setShowAddType(!showAddType)}>
+          <Button
+            onClick={() => setShowAddType(!showAddType)}
+            disabled={!hasPermission('maintenance:create')}
+            title={!hasPermission('maintenance:create') ? 'You do not have permission to create maintenance types' : undefined}
+          >
             <Plus className="w-4 h-4" />
             <Plus className="w-4 h-4" />
             Add Custom Type
             Add Custom Type
           </Button>
           </Button>
@@ -914,7 +932,9 @@ function SettingsSection({
                   </button>
                   </button>
                   <button
                   <button
                     onClick={() => startEditType(type)}
                     onClick={() => startEditType(type)}
-                    className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                    disabled={!hasPermission('maintenance:update')}
+                    title={!hasPermission('maintenance:update') ? 'You do not have permission to edit maintenance types' : undefined}
+                    className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors ${!hasPermission('maintenance:update') ? 'opacity-50 cursor-not-allowed' : ''}`}
                   >
                   >
                     <Edit3 className="w-4 h-4" />
                     <Edit3 className="w-4 h-4" />
                   </button>
                   </button>
@@ -924,7 +944,9 @@ function SettingsSection({
                         onDeleteType(type.id);
                         onDeleteType(type.id);
                       }
                       }
                     }}
                     }}
-                    className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
+                    disabled={!hasPermission('maintenance:delete')}
+                    title={!hasPermission('maintenance:delete') ? 'You do not have permission to delete maintenance types' : undefined}
+                    className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${!hasPermission('maintenance:delete') ? 'opacity-50 cursor-not-allowed' : ''}`}
                   >
                   >
                     <Trash2 className="w-4 h-4" />
                     <Trash2 className="w-4 h-4" />
                   </button>
                   </button>
@@ -946,8 +968,9 @@ function SettingsSection({
                             {p.printerName}
                             {p.printerName}
                             <button
                             <button
                               onClick={() => p.itemId && onRemoveItem(p.itemId)}
                               onClick={() => p.itemId && onRemoveItem(p.itemId)}
-                              className="hover:text-red-400 ml-1"
-                              title="Remove from this printer"
+                              disabled={!hasPermission('maintenance:delete')}
+                              title={!hasPermission('maintenance:delete') ? 'You do not have permission to remove printer assignments' : 'Remove from this printer'}
+                              className={`ml-1 ${hasPermission('maintenance:delete') ? 'hover:text-red-400' : 'opacity-50 cursor-not-allowed'}`}
                             >
                             >
                               ×
                               ×
                             </button>
                             </button>
@@ -962,7 +985,9 @@ function SettingsSection({
                           <button
                           <button
                             key={p.id}
                             key={p.id}
                             onClick={() => onAssignType(p.id, type.id)}
                             onClick={() => onAssignType(p.id, type.id)}
-                            className="px-2 py-1 bg-bambu-dark hover:bg-bambu-green/20 rounded text-xs text-bambu-gray hover:text-bambu-green transition-colors"
+                            disabled={!hasPermission('maintenance:create')}
+                            title={!hasPermission('maintenance:create') ? 'You do not have permission to assign printers' : undefined}
+                            className={`px-2 py-1 bg-bambu-dark rounded text-xs transition-colors ${hasPermission('maintenance:create') ? 'hover:bg-bambu-green/20 text-bambu-gray hover:text-bambu-green' : 'opacity-50 cursor-not-allowed text-bambu-gray'}`}
                           >
                           >
                             + {p.name}
                             + {p.name}
                           </button>
                           </button>
@@ -1034,11 +1059,14 @@ function SettingsSection({
                           ) : (
                           ) : (
                             <button
                             <button
                               onClick={() => {
                               onClick={() => {
+                                if (!hasPermission('maintenance:update')) return;
                                 setEditingInterval(item.id);
                                 setEditingInterval(item.id);
                                 setIntervalInput(item.interval_hours.toString());
                                 setIntervalInput(item.interval_hours.toString());
                                 setIntervalTypeInput(intervalType);
                                 setIntervalTypeInput(intervalType);
                               }}
                               }}
-                              className="px-2 py-1 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green rounded text-xs font-medium text-white transition-colors flex items-center gap-1"
+                              disabled={!hasPermission('maintenance:update')}
+                              title={!hasPermission('maintenance:update') ? 'You do not have permission to edit intervals' : undefined}
+                              className={`px-2 py-1 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs font-medium text-white transition-colors flex items-center gap-1 ${hasPermission('maintenance:update') ? 'hover:bg-bambu-dark-secondary hover:border-bambu-green' : 'opacity-50 cursor-not-allowed'}`}
                             >
                             >
                               {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
                               {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
                               {formatIntervalLabel(item.interval_hours, intervalType)}
                               {formatIntervalLabel(item.interval_hours, intervalType)}
@@ -1076,6 +1104,7 @@ type TabType = 'status' | 'settings';
 export function MaintenancePage() {
 export function MaintenancePage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [activeTab, setActiveTab] = useState<TabType>('status');
   const [activeTab, setActiveTab] = useState<TabType>('status');
 
 
   const { data: overview, isLoading } = useQuery({
   const { data: overview, isLoading } = useQuery({
@@ -1259,6 +1288,7 @@ export function MaintenancePage() {
                 onPerform={handlePerform}
                 onPerform={handlePerform}
                 onToggle={handleToggle}
                 onToggle={handleToggle}
                 onSetHours={handleSetHours}
                 onSetHours={handleSetHours}
+                hasPermission={hasPermission}
               />
               />
             ))
             ))
           ) : (
           ) : (
@@ -1293,6 +1323,7 @@ export function MaintenancePage() {
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
           onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
           onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
           onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
           onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
+          hasPermission={hasPermission}
         />
         />
       )}
       )}
     </div>
     </div>

+ 137 - 49
frontend/src/pages/PrintersPage.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
 import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
+import { useAuth } from '../contexts/AuthContext';
 import {
 import {
   Plus,
   Plus,
   Link,
   Link,
@@ -938,6 +939,7 @@ function PrinterCard({
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const navigate = useNavigate();
   const navigate = useNavigate();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = 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);
@@ -1093,6 +1095,12 @@ function PrinterCard({
     queryFn: () => api.getSmartPlugByPrinter(printer.id),
     queryFn: () => api.getSmartPlugByPrinter(printer.id),
   });
   });
 
 
+  // Fetch script plugs for this printer (for multi-device control)
+  const { data: scriptPlugs } = useQuery({
+    queryKey: ['scriptPlugsByPrinter', printer.id],
+    queryFn: () => api.getScriptPlugsByPrinter(printer.id),
+  });
+
   // Fetch smart plug status if plug exists (faster refresh for energy monitoring)
   // Fetch smart plug status if plug exists (faster refresh for energy monitoring)
   const { data: plugStatus } = useQuery({
   const { data: plugStatus } = useQuery({
     queryKey: ['smartPlugStatus', smartPlug?.id],
     queryKey: ['smartPlugStatus', smartPlug?.id],
@@ -1127,6 +1135,7 @@ function PrinterCard({
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
     },
     },
+    onError: (error: Error) => showToast(error.message || 'Failed to delete printer', 'error'),
   });
   });
 
 
   const connectMutation = useMutation({
   const connectMutation = useMutation({
@@ -1155,6 +1164,15 @@ function PrinterCard({
     },
     },
   });
   });
 
 
+  // Run script mutation
+  const runScriptMutation = useMutation({
+    mutationFn: (scriptId: number) => api.controlSmartPlug(scriptId, 'on'),
+    onSuccess: () => {
+      showToast('Script triggered');
+    },
+    onError: (error: Error) => showToast(error.message || 'Failed to run script', 'error'),
+  });
+
   // Print control mutations
   // Print control mutations
   const stopPrintMutation = useMutation({
   const stopPrintMutation = useMutation({
     mutationFn: () => api.stopPrint(printer.id),
     mutationFn: () => api.stopPrint(printer.id),
@@ -1560,11 +1578,17 @@ function PrinterCard({
               {showMenu && (
               {showMenu && (
                 <div className="absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10">
                 <div className="absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10">
                   <button
                   <button
-                    className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                    className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
+                      hasPermission('printers:update')
+                        ? 'hover:bg-bambu-dark-tertiary'
+                        : 'opacity-50 cursor-not-allowed'
+                    }`}
                     onClick={() => {
                     onClick={() => {
+                      if (!hasPermission('printers:update')) return;
                       setShowEditModal(true);
                       setShowEditModal(true);
                       setShowMenu(false);
                       setShowMenu(false);
                     }}
                     }}
+                    title={!hasPermission('printers:update') ? 'You do not have permission to edit printers' : undefined}
                   >
                   >
                     <Pencil className="w-4 h-4" />
                     <Pencil className="w-4 h-4" />
                     Edit
                     Edit
@@ -1590,11 +1614,17 @@ function PrinterCard({
                     MQTT Debug
                     MQTT Debug
                   </button>
                   </button>
                   <button
                   <button
-                    className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                    className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
+                      hasPermission('printers:delete')
+                        ? 'text-red-400 hover:bg-bambu-dark-tertiary'
+                        : 'text-red-400/50 cursor-not-allowed'
+                    }`}
                     onClick={() => {
                     onClick={() => {
+                      if (!hasPermission('printers:delete')) return;
                       setShowDeleteConfirm(true);
                       setShowDeleteConfirm(true);
                       setShowMenu(false);
                       setShowMenu(false);
                     }}
                     }}
+                    title={!hasPermission('printers:delete') ? 'You do not have permission to delete printers' : undefined}
                   >
                   >
                     <Trash2 className="w-4 h-4" />
                     <Trash2 className="w-4 h-4" />
                     Delete
                     Delete
@@ -1801,18 +1831,20 @@ function PrinterCard({
                   {/* Skip Objects button - top right corner, always visible */}
                   {/* Skip Objects button - top right corner, always visible */}
                   <button
                   <button
                     onClick={() => setShowSkipObjectsModal(true)}
                     onClick={() => setShowSkipObjectsModal(true)}
-                    disabled={!(status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED') || (status.printable_objects_count ?? 0) < 2}
+                    disabled={!(status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED') || (status.printable_objects_count ?? 0) < 2 || !hasPermission('printers:control')}
                     className={`absolute top-2 right-2 p-1.5 rounded transition-colors z-10 ${
                     className={`absolute top-2 right-2 p-1.5 rounded transition-colors z-10 ${
-                      (status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED') && (status.printable_objects_count ?? 0) >= 2
+                      (status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED') && (status.printable_objects_count ?? 0) >= 2 && hasPermission('printers:control')
                         ? 'text-bambu-gray hover:text-white hover:bg-white/10'
                         ? 'text-bambu-gray hover:text-white hover:bg-white/10'
                         : 'text-bambu-gray/30 cursor-not-allowed'
                         : 'text-bambu-gray/30 cursor-not-allowed'
                     }`}
                     }`}
                     title={
                     title={
-                      !(status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED')
-                        ? "Skip objects (only while printing)"
-                        : (status.printable_objects_count ?? 0) >= 2
-                          ? "Skip objects"
-                          : "Skip objects (requires 2+ objects)"
+                      !hasPermission('printers:control')
+                        ? "You do not have permission to control printers"
+                        : !(status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED')
+                          ? "Skip objects (only while printing)"
+                          : (status.printable_objects_count ?? 0) >= 2
+                            ? "Skip objects"
+                            : "Skip objects (requires 2+ objects)"
                     }
                     }
                   >
                   >
                     <SkipObjectsIcon className="w-4 h-4" />
                     <SkipObjectsIcon className="w-4 h-4" />
@@ -2027,16 +2059,16 @@ function PrinterCard({
                       {/* Stop button */}
                       {/* Stop button */}
                       <button
                       <button
                         onClick={() => setShowStopConfirm(true)}
                         onClick={() => setShowStopConfirm(true)}
-                        disabled={!isPrinting || isControlBusy}
+                        disabled={!isPrinting || isControlBusy || !hasPermission('printers:control')}
                         className={`
                         className={`
                           flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
                           flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
                           transition-colors
                           transition-colors
-                          ${isPrinting
+                          ${isPrinting && hasPermission('printers:control')
                             ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
                             ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
                             : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
                             : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
                           }
                           }
                         `}
                         `}
-                        title="Stop print"
+                        title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : 'Stop print'}
                       >
                       >
                         <Square className="w-3 h-3" />
                         <Square className="w-3 h-3" />
                         Stop
                         Stop
@@ -2045,18 +2077,18 @@ function PrinterCard({
                       {/* Pause/Resume button */}
                       {/* Pause/Resume button */}
                       <button
                       <button
                         onClick={() => isPaused ? setShowResumeConfirm(true) : setShowPauseConfirm(true)}
                         onClick={() => isPaused ? setShowResumeConfirm(true) : setShowPauseConfirm(true)}
-                        disabled={!isPrinting || isControlBusy}
+                        disabled={!isPrinting || isControlBusy || !hasPermission('printers:control')}
                         className={`
                         className={`
                           flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
                           flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
                           transition-colors
                           transition-colors
-                          ${isPrinting
+                          ${isPrinting && hasPermission('printers:control')
                             ? isPaused
                             ? isPaused
                               ? 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
                               ? 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
                               : 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
                               : 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
                             : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
                             : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
                           }
                           }
                         `}
                         `}
-                        title={isPaused ? 'Resume print' : 'Pause print'}
+                        title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : (isPaused ? 'Resume print' : 'Pause print')}
                       >
                       >
                         {isPaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
                         {isPaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
                         {isPaused ? 'Resume' : 'Pause'}
                         {isPaused ? 'Resume' : 'Pause'}
@@ -2234,13 +2266,19 @@ function PrinterCard({
                                     {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === slotIdx && (
                                     {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === slotIdx && (
                                       <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                       <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                         <button
                                         <button
-                                          className="w-full px-3 py-1.5 text-left text-xs text-white hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                                          className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
+                                            hasPermission('printers:control')
+                                              ? 'text-white hover:bg-bambu-dark-tertiary'
+                                              : 'text-bambu-gray/50 cursor-not-allowed'
+                                          }`}
                                           onClick={(e) => {
                                           onClick={(e) => {
                                             e.stopPropagation();
                                             e.stopPropagation();
+                                            if (!hasPermission('printers:control')) return;
                                             refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });
                                             refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });
                                             setAmsSlotMenu(null);
                                             setAmsSlotMenu(null);
                                           }}
                                           }}
-                                          disabled={isRefreshing}
+                                          disabled={isRefreshing || !hasPermission('printers:control')}
+                                          title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : undefined}
                                         >
                                         >
                                           <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
                                           <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
                                           Re-read RFID
                                           Re-read RFID
@@ -2266,7 +2304,7 @@ function PrinterCard({
                                           } : undefined,
                                           } : undefined,
                                         }}
                                         }}
                                         configureSlot={{
                                         configureSlot={{
-                                          enabled: true,
+                                          enabled: hasPermission('printers:control'),
                                           onConfigure: () => setConfigureSlotModal({
                                           onConfigure: () => setConfigureSlotModal({
                                             amsId: ams.id,
                                             amsId: ams.id,
                                             trayId: slotIdx,
                                             trayId: slotIdx,
@@ -2283,7 +2321,7 @@ function PrinterCard({
                                     ) : (
                                     ) : (
                                       <EmptySlotHoverCard
                                       <EmptySlotHoverCard
                                         configureSlot={{
                                         configureSlot={{
-                                          enabled: true,
+                                          enabled: hasPermission('printers:control'),
                                           onConfigure: () => setConfigureSlotModal({
                                           onConfigure: () => setConfigureSlotModal({
                                             amsId: ams.id,
                                             amsId: ams.id,
                                             trayId: slotIdx,
                                             trayId: slotIdx,
@@ -2417,13 +2455,19 @@ function PrinterCard({
                                 {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === htSlotId && (
                                 {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === htSlotId && (
                                   <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                   <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                     <button
                                     <button
-                                      className="w-full px-3 py-1.5 text-left text-xs text-white hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                                      className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
+                                        hasPermission('printers:control')
+                                          ? 'text-white hover:bg-bambu-dark-tertiary'
+                                          : 'text-bambu-gray/50 cursor-not-allowed'
+                                      }`}
                                       onClick={(e) => {
                                       onClick={(e) => {
                                         e.stopPropagation();
                                         e.stopPropagation();
+                                        if (!hasPermission('printers:control')) return;
                                         refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });
                                         refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });
                                         setAmsSlotMenu(null);
                                         setAmsSlotMenu(null);
                                       }}
                                       }}
-                                      disabled={isHtRefreshing}
+                                      disabled={isHtRefreshing || !hasPermission('printers:control')}
+                                      title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : undefined}
                                     >
                                     >
                                       <RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
                                       <RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
                                       Re-read RFID
                                       Re-read RFID
@@ -2449,7 +2493,7 @@ function PrinterCard({
                                       } : undefined,
                                       } : undefined,
                                     }}
                                     }}
                                     configureSlot={{
                                     configureSlot={{
-                                      enabled: true,
+                                      enabled: hasPermission('printers:control'),
                                       onConfigure: () => setConfigureSlotModal({
                                       onConfigure: () => setConfigureSlotModal({
                                         amsId: ams.id,
                                         amsId: ams.id,
                                         trayId: htSlotId,
                                         trayId: htSlotId,
@@ -2466,7 +2510,7 @@ function PrinterCard({
                                 ) : (
                                 ) : (
                                   <EmptySlotHoverCard
                                   <EmptySlotHoverCard
                                     configureSlot={{
                                     configureSlot={{
-                                      enabled: true,
+                                      enabled: hasPermission('printers:control'),
                                       onConfigure: () => setConfigureSlotModal({
                                       onConfigure: () => setConfigureSlotModal({
                                         amsId: ams.id,
                                         amsId: ams.id,
                                         trayId: htSlotId,
                                         trayId: htSlotId,
@@ -2577,7 +2621,7 @@ function PrinterCard({
                                 } : undefined,
                                 } : undefined,
                               }}
                               }}
                               configureSlot={{
                               configureSlot={{
-                                enabled: true,
+                                enabled: hasPermission('printers:control'),
                                 onConfigure: () => setConfigureSlotModal({
                                 onConfigure: () => setConfigureSlotModal({
                                   amsId: 255, // External spool indicator
                                   amsId: 255, // External spool indicator
                                   trayId: 0,
                                   trayId: 0,
@@ -2636,24 +2680,30 @@ function PrinterCard({
               <div className="flex items-center gap-1">
               <div className="flex items-center gap-1">
                 <button
                 <button
                   onClick={() => setShowPowerOnConfirm(true)}
                   onClick={() => setShowPowerOnConfirm(true)}
-                  disabled={powerControlMutation.isPending || plugStatus?.state === 'ON'}
+                  disabled={powerControlMutation.isPending || plugStatus?.state === 'ON' || !hasPermission('smart_plugs:control')}
                   className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
                   className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
-                    plugStatus?.state === 'ON'
-                      ? 'bg-bambu-green text-white'
-                      : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                    !hasPermission('smart_plugs:control')
+                      ? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
+                      : plugStatus?.state === 'ON'
+                        ? 'bg-bambu-green text-white'
+                        : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
                   }`}
                   }`}
+                  title={!hasPermission('smart_plugs:control') ? 'You do not have permission to control smart plugs' : undefined}
                 >
                 >
                   <Power className="w-3 h-3" />
                   <Power className="w-3 h-3" />
                   On
                   On
                 </button>
                 </button>
                 <button
                 <button
                   onClick={() => setShowPowerOffConfirm(true)}
                   onClick={() => setShowPowerOffConfirm(true)}
-                  disabled={powerControlMutation.isPending || plugStatus?.state === 'OFF'}
+                  disabled={powerControlMutation.isPending || plugStatus?.state === 'OFF' || !hasPermission('smart_plugs:control')}
                   className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
                   className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
-                    plugStatus?.state === 'OFF'
-                      ? 'bg-red-500/30 text-red-400'
-                      : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                    !hasPermission('smart_plugs:control')
+                      ? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
+                      : plugStatus?.state === 'OFF'
+                        ? 'bg-red-500/30 text-red-400'
+                        : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
                   }`}
                   }`}
+                  title={!hasPermission('smart_plugs:control') ? 'You do not have permission to control smart plugs' : undefined}
                 >
                 >
                   <PowerOff className="w-3 h-3" />
                   <PowerOff className="w-3 h-3" />
                   Off
                   Off
@@ -2667,12 +2717,14 @@ function PrinterCard({
                 </span>
                 </span>
                 <button
                 <button
                   onClick={() => toggleAutoOffMutation.mutate(!smartPlug.auto_off)}
                   onClick={() => toggleAutoOffMutation.mutate(!smartPlug.auto_off)}
-                  disabled={toggleAutoOffMutation.isPending || smartPlug.auto_off_executed}
-                  title={smartPlug.auto_off_executed ? 'Auto-off was executed - turn printer on to reset' : 'Auto power-off after print'}
+                  disabled={toggleAutoOffMutation.isPending || smartPlug.auto_off_executed || !hasPermission('smart_plugs:control')}
+                  title={!hasPermission('smart_plugs:control') ? 'You do not have permission to control smart plugs' : (smartPlug.auto_off_executed ? 'Auto-off was executed - turn printer on to reset' : 'Auto power-off after print')}
                   className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
                   className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
-                    smartPlug.auto_off_executed
-                      ? 'bg-bambu-green/50 cursor-not-allowed'
-                      : smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                    !hasPermission('smart_plugs:control')
+                      ? 'bg-bambu-dark-tertiary/50 cursor-not-allowed'
+                      : smartPlug.auto_off_executed
+                        ? 'bg-bambu-green/50 cursor-not-allowed'
+                        : smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
                   }`}
                   }`}
                 >
                 >
                   <span
                   <span
@@ -2683,6 +2735,28 @@ function PrinterCard({
                 </button>
                 </button>
               </div>
               </div>
             </div>
             </div>
+
+            {/* Script buttons row */}
+            {scriptPlugs && scriptPlugs.length > 0 && (
+              <div className="flex items-center gap-2 mt-2 pt-2 border-t border-bambu-dark-tertiary/50">
+                <Play className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />
+                <span className="text-xs text-bambu-gray">Scripts:</span>
+                <div className="flex flex-wrap gap-1">
+                  {scriptPlugs.map(script => (
+                    <button
+                      key={script.id}
+                      onClick={() => runScriptMutation.mutate(script.id)}
+                      disabled={runScriptMutation.isPending}
+                      title={`Run ${script.ha_entity_id}`}
+                      className="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 rounded transition-colors flex items-center gap-1"
+                    >
+                      <Play className="w-2.5 h-2.5" />
+                      {script.name}
+                    </button>
+                  ))}
+                </div>
+              </div>
+            )}
           </div>
           </div>
         )}
         )}
 
 
@@ -2699,8 +2773,8 @@ function PrinterCard({
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
                 onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
                 onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
-                disabled={!status?.connected || chamberLightMutation.isPending}
-                title={status?.chamber_light ? 'Turn off chamber light' : 'Turn on chamber light'}
+                disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
+                title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : (status?.chamber_light ? 'Turn off chamber light' : 'Turn on chamber light')}
                 className={status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30 border-yellow-500/30' : ''}
                 className={status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30 border-yellow-500/30' : ''}
               >
               >
                 <ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
                 <ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
@@ -2737,8 +2811,8 @@ function PrinterCard({
                   variant="secondary"
                   variant="secondary"
                   size="sm"
                   size="sm"
                   onClick={handleTogglePlateDetection}
                   onClick={handleTogglePlateDetection}
-                  disabled={!status?.connected || plateDetectionMutation.isPending}
-                  title={printer.plate_detection_enabled ? "Plate check enabled - Click to disable" : "Plate check disabled - Click to enable"}
+                  disabled={!status?.connected || plateDetectionMutation.isPending || !hasPermission('printers:update')}
+                  title={!hasPermission('printers:update') ? "You do not have permission to update printers" : (printer.plate_detection_enabled ? "Plate check enabled - Click to disable" : "Plate check disabled - Click to enable")}
                   className={`!rounded-r-none !border-r-0 ${printer.plate_detection_enabled ? "!border-green-500 !text-green-400 hover:!bg-green-500/20" : ""}`}
                   className={`!rounded-r-none !border-r-0 ${printer.plate_detection_enabled ? "!border-green-500 !text-green-400 hover:!bg-green-500/20" : ""}`}
                 >
                 >
                   {plateDetectionMutation.isPending ? (
                   {plateDetectionMutation.isPending ? (
@@ -2751,8 +2825,8 @@ function PrinterCard({
                   variant="secondary"
                   variant="secondary"
                   size="sm"
                   size="sm"
                   onClick={handleOpenPlateManagement}
                   onClick={handleOpenPlateManagement}
-                  disabled={!status?.connected || isCheckingPlate}
-                  title="Manage plate detection calibration"
+                  disabled={!status?.connected || isCheckingPlate || !hasPermission('printers:update')}
+                  title={!hasPermission('printers:update') ? "You do not have permission to update printers" : "Manage plate detection calibration"}
                   className={`!rounded-l-none !px-1.5 ${printer.plate_detection_enabled ? "!border-green-500 !text-green-400 hover:!bg-green-500/20" : ""}`}
                   className={`!rounded-l-none !px-1.5 ${printer.plate_detection_enabled ? "!border-green-500 !text-green-400 hover:!bg-green-500/20" : ""}`}
                 >
                 >
                   {isCheckingPlate ? (
                   {isCheckingPlate ? (
@@ -2766,7 +2840,8 @@ function PrinterCard({
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
                 onClick={() => setShowFileManager(true)}
                 onClick={() => setShowFileManager(true)}
-                title="Browse printer files"
+                disabled={!hasPermission('printers:files')}
+                title={!hasPermission('printers:files') ? 'You do not have permission to access printer files' : 'Browse printer files'}
               >
               >
                 <HardDrive className="w-4 h-4" />
                 <HardDrive className="w-4 h-4" />
                 Files
                 Files
@@ -3332,13 +3407,13 @@ function PrinterCard({
                       {!obj.skipped ? (
                       {!obj.skipped ? (
                         <button
                         <button
                           onClick={() => skipObjectsMutation.mutate([obj.id])}
                           onClick={() => skipObjectsMutation.mutate([obj.id])}
-                          disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1}
+                          disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
                           className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
                           className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
-                            (status?.layer_num ?? 0) <= 1
+                            (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
                               ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
                               ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
                               : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
                               : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
                           }`}
                           }`}
-                          title={(status?.layer_num ?? 0) <= 1 ? 'Wait for layer 2+' : 'Skip this object'}
+                          title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : ((status?.layer_num ?? 0) <= 1 ? 'Wait for layer 2+' : 'Skip this object')}
                         >
                         >
                           Skip
                           Skip
                         </button>
                         </button>
@@ -4030,6 +4105,7 @@ function EditPrinterModal({
   onClose: () => void;
   onClose: () => void;
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
+  const { showToast } = useToast();
   const [form, setForm] = useState({
   const [form, setForm] = useState({
     name: printer.name,
     name: printer.name,
     ip_address: printer.ip_address,
     ip_address: printer.ip_address,
@@ -4046,6 +4122,7 @@ function EditPrinterModal({
       queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
       queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
       onClose();
       onClose();
     },
     },
+    onError: (error: Error) => showToast(error.message || 'Failed to update printer', 'error'),
   });
   });
 
 
   // Close on Escape key
   // Close on Escape key
@@ -4282,6 +4359,8 @@ export function PrintersPage() {
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   // Embedded camera viewer state - supports multiple simultaneous viewers
   // Embedded camera viewer state - supports multiple simultaneous viewers
   // Persisted to localStorage so cameras reopen after navigation
   // Persisted to localStorage so cameras reopen after navigation
   const [embeddedCameraPrinters, setEmbeddedCameraPrinters] = useState<Map<number, { id: number; name: string }>>(() => {
   const [embeddedCameraPrinters, setEmbeddedCameraPrinters] = useState<Map<number, { id: number; name: string }>>(() => {
@@ -4387,6 +4466,7 @@ export function PrintersPage() {
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
       setShowAddModal(false);
       setShowAddModal(false);
     },
     },
+    onError: (error: Error) => showToast(error.message || 'Failed to add printer', 'error'),
   });
   });
 
 
   const powerOnMutation = useMutation({
   const powerOnMutation = useMutation({
@@ -4607,7 +4687,11 @@ export function PrintersPage() {
               )}
               )}
             </div>
             </div>
           )}
           )}
-          <Button onClick={() => setShowAddModal(true)}>
+          <Button
+            onClick={() => setShowAddModal(true)}
+            disabled={!hasPermission('printers:create')}
+            title={!hasPermission('printers:create') ? 'You do not have permission to add printers' : undefined}
+          >
             <Plus className="w-4 h-4" />
             <Plus className="w-4 h-4" />
             Add Printer
             Add Printer
           </Button>
           </Button>
@@ -4620,7 +4704,11 @@ export function PrintersPage() {
         <Card>
         <Card>
           <CardContent className="text-center py-12">
           <CardContent className="text-center py-12">
             <p className="text-bambu-gray mb-4">No printers configured yet</p>
             <p className="text-bambu-gray mb-4">No printers configured yet</p>
-            <Button onClick={() => setShowAddModal(true)}>
+            <Button
+              onClick={() => setShowAddModal(true)}
+              disabled={!hasPermission('printers:create')}
+              title={!hasPermission('printers:create') ? 'You do not have permission to add printers' : undefined}
+            >
               <Plus className="w-4 h-4" />
               <Plus className="w-4 h-4" />
               Add Your First Printer
               Add Your First Printer
             </Button>
             </Button>

+ 46 - 8
frontend/src/pages/ProfilesPage.tsx

@@ -42,10 +42,11 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate } from '../utils/date';
 import { parseUTCDate } from '../utils/date';
-import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition } from '../api/client';
+import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 import { KProfilesView } from '../components/KProfilesView';
 import { KProfilesView } from '../components/KProfilesView';
 
 
 type ProfileTab = 'cloud' | 'kprofiles';
 type ProfileTab = 'cloud' | 'kprofiles';
@@ -506,12 +507,14 @@ function PresetDetailModal({
   onDeleted,
   onDeleted,
   onDuplicate,
   onDuplicate,
   onEdit,
   onEdit,
+  hasPermission,
 }: {
 }: {
   setting: SlicerSetting;
   setting: SlicerSetting;
   onClose: () => void;
   onClose: () => void;
   onDeleted: () => void;
   onDeleted: () => void;
   onDuplicate: () => void;
   onDuplicate: () => void;
   onEdit: () => void;
   onEdit: () => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
   const { showToast } = useToast();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -599,17 +602,32 @@ function PresetDetailModal({
             <div className="flex-shrink-0 p-4 border-t border-bambu-dark-tertiary">
             <div className="flex-shrink-0 p-4 border-t border-bambu-dark-tertiary">
               <div className="flex gap-2">
               <div className="flex gap-2">
                 <Button variant="secondary" onClick={onClose} className="flex-1">Close</Button>
                 <Button variant="secondary" onClick={onClose} className="flex-1">Close</Button>
-                <Button variant="secondary" onClick={onDuplicate}>
+                <Button
+                  variant="secondary"
+                  onClick={onDuplicate}
+                  disabled={!hasPermission('cloud:auth')}
+                  title={!hasPermission('cloud:auth') ? 'You do not have permission to duplicate presets' : undefined}
+                >
                   <Copy className="w-4 h-4" />
                   <Copy className="w-4 h-4" />
                   Duplicate
                   Duplicate
                 </Button>
                 </Button>
                 {isEditable && (
                 {isEditable && (
                   <>
                   <>
-                    <Button variant="secondary" onClick={onEdit} disabled={isLoading || !detail}>
+                    <Button
+                      variant="secondary"
+                      onClick={onEdit}
+                      disabled={isLoading || !detail || !hasPermission('cloud:auth')}
+                      title={!hasPermission('cloud:auth') ? 'You do not have permission to edit presets' : undefined}
+                    >
                       <Pencil className="w-4 h-4" />
                       <Pencil className="w-4 h-4" />
                       Edit
                       Edit
                     </Button>
                     </Button>
-                    <Button variant="danger" onClick={() => setShowDeleteConfirm(true)}>
+                    <Button
+                      variant="danger"
+                      onClick={() => setShowDeleteConfirm(true)}
+                      disabled={!hasPermission('cloud:auth')}
+                      title={!hasPermission('cloud:auth') ? 'You do not have permission to delete presets' : undefined}
+                    >
                       <Trash2 className="w-4 h-4" />
                       <Trash2 className="w-4 h-4" />
                     </Button>
                     </Button>
                   </>
                   </>
@@ -2206,12 +2224,14 @@ function CloudProfilesView({
   onRefresh,
   onRefresh,
   isRefreshing,
   isRefreshing,
   printers,
   printers,
+  hasPermission,
 }: {
 }: {
   settings: SlicerSettingsResponse;
   settings: SlicerSettingsResponse;
   lastSyncTime?: Date;
   lastSyncTime?: Date;
   onRefresh: () => void;
   onRefresh: () => void;
   isRefreshing: boolean;
   isRefreshing: boolean;
   printers: Printer[];
   printers: Printer[];
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [filterType, setFilterType] = useState<PresetType>('all');
   const [filterType, setFilterType] = useState<PresetType>('all');
@@ -2424,15 +2444,29 @@ function CloudProfilesView({
               <GitCompare className="w-4 h-4" />
               <GitCompare className="w-4 h-4" />
               {compareMode ? 'Cancel' : 'Compare'}
               {compareMode ? 'Cancel' : 'Compare'}
             </Button>
             </Button>
-            <Button variant="secondary" onClick={() => setShowTemplatesModal(true)}>
+            <Button
+              variant="secondary"
+              onClick={() => setShowTemplatesModal(true)}
+              disabled={!hasPermission('cloud:auth')}
+              title={!hasPermission('cloud:auth') ? 'You do not have permission to manage templates' : undefined}
+            >
               <Sparkles className="w-4 h-4" />
               <Sparkles className="w-4 h-4" />
               Templates
               Templates
             </Button>
             </Button>
-            <Button variant="secondary" onClick={onRefresh} disabled={isRefreshing}>
+            <Button
+              variant="secondary"
+              onClick={onRefresh}
+              disabled={isRefreshing || !hasPermission('cloud:auth')}
+              title={!hasPermission('cloud:auth') ? 'You do not have permission to refresh profiles' : undefined}
+            >
               <RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
               <RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
               Refresh
               Refresh
             </Button>
             </Button>
-            <Button onClick={() => setShowCreateModal(true)}>
+            <Button
+              onClick={() => setShowCreateModal(true)}
+              disabled={!hasPermission('cloud:auth')}
+              title={!hasPermission('cloud:auth') ? 'You do not have permission to create presets' : undefined}
+            >
               <Plus className="w-4 h-4" />
               <Plus className="w-4 h-4" />
               New Preset
               New Preset
             </Button>
             </Button>
@@ -2704,6 +2738,7 @@ function CloudProfilesView({
           onDeleted={() => setSelectedSetting(null)}
           onDeleted={() => setSelectedSetting(null)}
           onDuplicate={() => handleDuplicate(selectedSetting)}
           onDuplicate={() => handleDuplicate(selectedSetting)}
           onEdit={() => handleEdit(selectedSetting)}
           onEdit={() => handleEdit(selectedSetting)}
+          hasPermission={hasPermission}
         />
         />
       )}
       )}
 
 
@@ -2748,6 +2783,7 @@ function CloudProfilesView({
 export function ProfilesPage() {
 export function ProfilesPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [activeTab, setActiveTab] = useState<ProfileTab>('cloud');
   const [activeTab, setActiveTab] = useState<ProfileTab>('cloud');
   const [lastSyncTime, setLastSyncTime] = useState<Date>();
   const [lastSyncTime, setLastSyncTime] = useState<Date>();
 
 
@@ -2846,7 +2882,8 @@ export function ProfilesPage() {
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
                 onClick={() => logoutMutation.mutate()}
                 onClick={() => logoutMutation.mutate()}
-                disabled={logoutMutation.isPending}
+                disabled={logoutMutation.isPending || !hasPermission('cloud:auth')}
+                title={!hasPermission('cloud:auth') ? 'You do not have permission to logout' : undefined}
               >
               >
                 <LogOut className="w-4 h-4" />
                 <LogOut className="w-4 h-4" />
                 Logout
                 Logout
@@ -2867,6 +2904,7 @@ export function ProfilesPage() {
               onRefresh={() => refetchSettings()}
               onRefresh={() => refetchSettings()}
               isRefreshing={settingsLoading}
               isRefreshing={settingsLoading}
               printers={printers}
               printers={printers}
+              hasPermission={hasPermission}
             />
             />
           ) : (
           ) : (
             <div className="text-center py-16">
             <div className="text-center py-16">

+ 51 - 14
frontend/src/pages/ProjectDetailPage.tsx

@@ -36,6 +36,7 @@ import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } fr
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 import { RichTextEditor } from '../components/RichTextEditor';
 import { RichTextEditor } from '../components/RichTextEditor';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 
 
@@ -198,6 +199,7 @@ export function ProjectDetailPage() {
   const navigate = useNavigate();
   const navigate = useNavigate();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [showEditModal, setShowEditModal] = useState(false);
   const [showEditModal, setShowEditModal] = useState(false);
   const [editingNotes, setEditingNotes] = useState(false);
   const [editingNotes, setEditingNotes] = useState(false);
   const [notesContent, setNotesContent] = useState('');
   const [notesContent, setNotesContent] = useState('');
@@ -494,11 +496,20 @@ export function ProjectDetailPage() {
           <StatusBadge status={project.status} />
           <StatusBadge status={project.status} />
         </div>
         </div>
         <div className="flex gap-2">
         <div className="flex gap-2">
-          <Button variant="secondary" onClick={handleExportProject} title="Export project">
+          <Button
+            variant="secondary"
+            onClick={handleExportProject}
+            disabled={!hasPermission('projects:read')}
+            title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export project'}
+          >
             <Download className="w-4 h-4 mr-2" />
             <Download className="w-4 h-4 mr-2" />
             Export
             Export
           </Button>
           </Button>
-          <Button onClick={() => setShowEditModal(true)}>
+          <Button
+            onClick={() => setShowEditModal(true)}
+            disabled={!hasPermission('projects:update')}
+            title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
+          >
             <Edit3 className="w-4 h-4 mr-2" />
             <Edit3 className="w-4 h-4 mr-2" />
             Edit
             Edit
           </Button>
           </Button>
@@ -761,7 +772,13 @@ export function ProjectDetailPage() {
               Notes
               Notes
             </h2>
             </h2>
             {!editingNotes ? (
             {!editingNotes ? (
-              <Button variant="secondary" size="sm" onClick={handleStartEditNotes}>
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={handleStartEditNotes}
+                disabled={!hasPermission('projects:update')}
+                title={!hasPermission('projects:update') ? 'You do not have permission to edit notes' : undefined}
+              >
                 <Edit3 className="w-4 h-4 mr-1" />
                 <Edit3 className="w-4 h-4 mr-1" />
                 Edit
                 Edit
               </Button>
               </Button>
@@ -886,7 +903,13 @@ export function ProjectDetailPage() {
                 </button>
                 </button>
               )}
               )}
               {!showBomForm && (
               {!showBomForm && (
-                <Button variant="secondary" size="sm" onClick={() => setShowBomForm(true)}>
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => setShowBomForm(true)}
+                  disabled={!hasPermission('projects:update')}
+                  title={!hasPermission('projects:update') ? 'You do not have permission to add parts' : undefined}
+                >
                   <Plus className="w-4 h-4 mr-1" />
                   <Plus className="w-4 h-4 mr-1" />
                   Add Part
                   Add Part
                 </Button>
                 </Button>
@@ -1031,12 +1054,15 @@ export function ProjectDetailPage() {
                     // Display mode
                     // Display mode
                     <div className="flex items-start gap-3">
                     <div className="flex items-start gap-3">
                       <button
                       <button
-                        onClick={() => handleToggleAcquired(item)}
-                        disabled={updateBomMutation.isPending}
+                        onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
+                        disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
+                        title={!hasPermission('projects:update') ? 'You do not have permission to update parts' : undefined}
                         className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
                         className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
                           item.is_complete
                           item.is_complete
                             ? 'bg-status-ok border-status-ok text-white'
                             ? 'bg-status-ok border-status-ok text-white'
-                            : 'border-bambu-gray hover:border-bambu-green'
+                            : hasPermission('projects:update')
+                              ? 'border-bambu-gray hover:border-bambu-green'
+                              : 'border-bambu-gray/50 cursor-not-allowed'
                         }`}
                         }`}
                       >
                       >
                         {item.is_complete && <CheckCircle className="w-3 h-3" />}
                         {item.is_complete && <CheckCircle className="w-3 h-3" />}
@@ -1058,16 +1084,26 @@ export function ProjectDetailPage() {
                           </div>
                           </div>
                           <div className="flex items-center gap-1">
                           <div className="flex items-center gap-1">
                             <button
                             <button
-                              onClick={() => handleEditBomItem(item)}
-                              className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0"
-                              title="Edit"
+                              onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}
+                              disabled={!hasPermission('projects:update')}
+                              className={`p-1 rounded transition-colors flex-shrink-0 ${
+                                hasPermission('projects:update')
+                                  ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                                  : 'text-bambu-gray/50 cursor-not-allowed'
+                              }`}
+                              title={!hasPermission('projects:update') ? 'You do not have permission to edit parts' : 'Edit'}
                             >
                             >
                               <Pencil className="w-4 h-4" />
                               <Pencil className="w-4 h-4" />
                             </button>
                             </button>
                             <button
                             <button
-                              onClick={() => handleDeleteBomItem(item.id, item.name)}
-                              className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
-                              title="Delete"
+                              onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}
+                              disabled={!hasPermission('projects:update')}
+                              className={`p-1 rounded transition-colors flex-shrink-0 ${
+                                hasPermission('projects:update')
+                                  ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
+                                  : 'text-bambu-gray/50 cursor-not-allowed'
+                              }`}
+                              title={!hasPermission('projects:update') ? 'You do not have permission to delete parts' : 'Delete'}
                             >
                             >
                               <Trash2 className="w-4 h-4" />
                               <Trash2 className="w-4 h-4" />
                             </button>
                             </button>
@@ -1178,7 +1214,8 @@ export function ProjectDetailPage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => createTemplateMutation.mutate()}
             onClick={() => createTemplateMutation.mutate()}
-            disabled={createTemplateMutation.isPending}
+            disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
+            title={!hasPermission('projects:create') ? 'You do not have permission to create templates' : undefined}
           >
           >
             {createTemplateMutation.isPending ? (
             {createTemplateMutation.isPending ? (
               <Loader2 className="w-4 h-4 animate-spin mr-2" />
               <Loader2 className="w-4 h-4 animate-spin mr-2" />

+ 41 - 10
frontend/src/pages/ProjectsPage.tsx

@@ -20,10 +20,11 @@ import {
   Upload,
   Upload,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport } from '../api/client';
+import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 
 const PROJECT_COLORS = [
 const PROJECT_COLORS = [
   '#ef4444', // red
   '#ef4444', // red
@@ -244,9 +245,10 @@ interface ProjectCardProps {
   onClick: () => void;
   onClick: () => void;
   onEdit: () => void;
   onEdit: () => void;
   onDelete: () => void;
   onDelete: () => void;
+  hasPermission: (permission: Permission) => boolean;
 }
 }
 
 
-function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
+function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: ProjectCardProps) {
   // Plates progress: archive_count / target_count
   // Plates progress: archive_count / target_count
   const platesProgressPercent = project.target_count
   const platesProgressPercent = project.target_count
     ? Math.round((project.archive_count / project.target_count) * 100)
     ? Math.round((project.archive_count / project.target_count) * 100)
@@ -389,15 +391,23 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
                 <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
                 <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
                 <div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                 <div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                   <button
                   <button
-                    className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                    onClick={() => { onEdit(); setShowActions(false); }}
+                    className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
+                      hasPermission('projects:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    }`}
+                    onClick={() => { if (hasPermission('projects:update')) { onEdit(); setShowActions(false); } }}
+                    disabled={!hasPermission('projects:update')}
+                    title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
                   >
                   >
                     <Edit3 className="w-4 h-4" />
                     <Edit3 className="w-4 h-4" />
                     Edit
                     Edit
                   </button>
                   </button>
                   <button
                   <button
-                    className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
-                    onClick={() => { onDelete(); setShowActions(false); }}
+                    className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
+                      hasPermission('projects:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    }`}
+                    onClick={() => { if (hasPermission('projects:delete')) { onDelete(); setShowActions(false); } }}
+                    disabled={!hasPermission('projects:delete')}
+                    title={!hasPermission('projects:delete') ? 'You do not have permission to delete projects' : undefined}
                   >
                   >
                     <Trash2 className="w-4 h-4" />
                     <Trash2 className="w-4 h-4" />
                     Delete
                     Delete
@@ -565,6 +575,7 @@ export function ProjectsPage() {
   const navigate = useNavigate();
   const navigate = useNavigate();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [showModal, setShowModal] = useState(false);
   const [showModal, setShowModal] = useState(false);
   const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
   const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
   const [statusFilter, setStatusFilter] = useState<string>('active');
   const [statusFilter, setStatusFilter] = useState<string>('active');
@@ -763,15 +774,30 @@ export function ProjectsPage() {
           </p>
           </p>
         </div>
         </div>
         <div className="flex gap-2">
         <div className="flex gap-2">
-          <Button variant="secondary" onClick={handleImportClick} title="Import project">
+          <Button
+            variant="secondary"
+            onClick={handleImportClick}
+            disabled={!hasPermission('projects:create')}
+            title={!hasPermission('projects:create') ? 'You do not have permission to import projects' : 'Import project'}
+          >
             <Upload className="w-4 h-4 mr-2" />
             <Upload className="w-4 h-4 mr-2" />
             Import
             Import
           </Button>
           </Button>
-          <Button variant="secondary" onClick={handleExportAll} title="Export all projects">
+          <Button
+            variant="secondary"
+            onClick={handleExportAll}
+            disabled={!hasPermission('projects:read')}
+            title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export all projects'}
+          >
             <Download className="w-4 h-4 mr-2" />
             <Download className="w-4 h-4 mr-2" />
             Export
             Export
           </Button>
           </Button>
-          <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
+          <Button
+            onClick={() => setShowModal(true)}
+            className="sm:w-auto w-full"
+            disabled={!hasPermission('projects:create')}
+            title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
+          >
             <Plus className="w-4 h-4 mr-2" />
             <Plus className="w-4 h-4 mr-2" />
             New Project
             New Project
           </Button>
           </Button>
@@ -831,7 +857,11 @@ export function ProjectsPage() {
             }
             }
           </p>
           </p>
           {statusFilter === 'all' && (
           {statusFilter === 'all' && (
-            <Button onClick={() => setShowModal(true)}>
+            <Button
+              onClick={() => setShowModal(true)}
+              disabled={!hasPermission('projects:create')}
+              title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
+            >
               <Plus className="w-4 h-4 mr-2" />
               <Plus className="w-4 h-4 mr-2" />
               Create Your First Project
               Create Your First Project
             </Button>
             </Button>
@@ -846,6 +876,7 @@ export function ProjectsPage() {
               onClick={() => handleClick(project)}
               onClick={() => handleClick(project)}
               onEdit={() => handleEdit(project)}
               onEdit={() => handleEdit(project)}
               onDelete={() => handleDeleteClick(project.id)}
               onDelete={() => handleDeleteClick(project.id)}
+              hasPermission={hasPermission}
             />
             />
           ))}
           ))}
         </div>
         </div>

+ 28 - 9
frontend/src/pages/QueuePage.tsx

@@ -47,12 +47,13 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
-import type { PrintQueueItem, PrintQueueBulkUpdate } from '../api/client';
+import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 
 function formatDuration(seconds: number | null | undefined): string {
 function formatDuration(seconds: number | null | undefined): string {
   if (!seconds) return '--';
   if (!seconds) return '--';
@@ -277,6 +278,7 @@ function SortableQueueItem({
   timeFormat = 'system',
   timeFormat = 'system',
   isSelected = false,
   isSelected = false,
   onToggleSelect,
   onToggleSelect,
+  hasPermission,
 }: {
 }: {
   item: PrintQueueItem;
   item: PrintQueueItem;
   position?: number;
   position?: number;
@@ -289,7 +291,9 @@ function SortableQueueItem({
   timeFormat?: TimeFormat;
   timeFormat?: TimeFormat;
   isSelected?: boolean;
   isSelected?: boolean;
   onToggleSelect?: () => void;
   onToggleSelect?: () => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
+  const canReorder = hasPermission('queue:reorder');
   const {
   const {
     attributes,
     attributes,
     listeners,
     listeners,
@@ -297,7 +301,7 @@ function SortableQueueItem({
     transform,
     transform,
     transition,
     transition,
     isDragging,
     isDragging,
-  } = useSortable({ id: item.id, disabled: item.status !== 'pending' });
+  } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder });
 
 
   const style = {
   const style = {
     transform: CSS.Transform.toString(transform),
     transform: CSS.Transform.toString(transform),
@@ -489,7 +493,8 @@ function SortableQueueItem({
               variant="ghost"
               variant="ghost"
               size="sm"
               size="sm"
               onClick={onStop}
               onClick={onStop}
-              title="Stop Print"
+              disabled={!hasPermission('printers:control')}
+              title={!hasPermission('printers:control') ? 'You do not have permission to stop prints' : 'Stop Print'}
               className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
               className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
             >
             >
               <StopCircle className="w-4 h-4" />
               <StopCircle className="w-4 h-4" />
@@ -502,7 +507,8 @@ function SortableQueueItem({
                   variant="ghost"
                   variant="ghost"
                   size="sm"
                   size="sm"
                   onClick={onStart}
                   onClick={onStart}
-                  title="Start Print"
+                  disabled={!hasPermission('printers:control')}
+                  title={!hasPermission('printers:control') ? 'You do not have permission to start prints' : 'Start Print'}
                   className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
                   className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
                 >
                 >
                   <Play className="w-4 h-4" />
                   <Play className="w-4 h-4" />
@@ -512,7 +518,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onEdit}
                 onClick={onEdit}
-                title="Edit"
+                disabled={!hasPermission('queue:update')}
+                title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : 'Edit'}
               >
               >
                 <Pencil className="w-4 h-4" />
                 <Pencil className="w-4 h-4" />
               </Button>
               </Button>
@@ -520,7 +527,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onCancel}
                 onClick={onCancel}
-                title="Cancel"
+                disabled={!hasPermission('queue:delete')}
+                title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : 'Cancel'}
                 className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
                 className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
               >
               >
                 <X className="w-4 h-4" />
                 <X className="w-4 h-4" />
@@ -533,7 +541,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onRequeue}
                 onClick={onRequeue}
-                title="Re-queue"
+                disabled={!hasPermission('queue:create')}
+                title={!hasPermission('queue:create') ? 'You do not have permission to re-queue items' : 'Re-queue'}
                 className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
                 className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
               >
               >
                 <RefreshCw className="w-4 h-4" />
                 <RefreshCw className="w-4 h-4" />
@@ -542,7 +551,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onRemove}
                 onClick={onRemove}
-                title="Remove"
+                disabled={!hasPermission('queue:delete')}
+                title={!hasPermission('queue:delete') ? 'You do not have permission to remove queue items' : 'Remove'}
               >
               >
                 <Trash2 className="w-4 h-4" />
                 <Trash2 className="w-4 h-4" />
               </Button>
               </Button>
@@ -557,6 +567,7 @@ function SortableQueueItem({
 export function QueuePage() {
 export function QueuePage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
@@ -910,6 +921,8 @@ export function QueuePage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowClearHistoryConfirm(true)}
             onClick={() => setShowClearHistoryConfirm(true)}
+            disabled={!hasPermission('queue:delete')}
+            title={!hasPermission('queue:delete') ? 'You do not have permission to clear history' : undefined}
           >
           >
             <Trash2 className="w-4 h-4" />
             <Trash2 className="w-4 h-4" />
             Clear History
             Clear History
@@ -949,6 +962,7 @@ export function QueuePage() {
                     onRequeue={() => {}}
                     onRequeue={() => {}}
                     onStart={() => {}}
                     onStart={() => {}}
                     timeFormat={timeFormat}
                     timeFormat={timeFormat}
+                    hasPermission={hasPermission}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -1018,6 +1032,8 @@ export function QueuePage() {
                       size="sm"
                       size="sm"
                       onClick={() => setShowBulkEditModal(true)}
                       onClick={() => setShowBulkEditModal(true)}
                       className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
                       className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
+                      disabled={!hasPermission('queue:update')}
+                      title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : undefined}
                     >
                     >
                       <Pencil className="w-4 h-4" />
                       <Pencil className="w-4 h-4" />
                       Edit Selected
                       Edit Selected
@@ -1027,7 +1043,8 @@ export function QueuePage() {
                       size="sm"
                       size="sm"
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       className="flex items-center gap-2 text-red-400 hover:text-red-300"
                       className="flex items-center gap-2 text-red-400 hover:text-red-300"
-                      disabled={bulkCancelMutation.isPending}
+                      disabled={bulkCancelMutation.isPending || !hasPermission('queue:delete')}
+                      title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : undefined}
                     >
                     >
                       <X className="w-4 h-4" />
                       <X className="w-4 h-4" />
                       Cancel Selected
                       Cancel Selected
@@ -1060,6 +1077,7 @@ export function QueuePage() {
                         timeFormat={timeFormat}
                         timeFormat={timeFormat}
                         isSelected={selectedItems.includes(item.id)}
                         isSelected={selectedItems.includes(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
+                        hasPermission={hasPermission}
                       />
                       />
                     ))}
                     ))}
                   </div>
                   </div>
@@ -1113,6 +1131,7 @@ export function QueuePage() {
                     onRequeue={() => setRequeueItem(item)}
                     onRequeue={() => setRequeueItem(item)}
                     onStart={() => {}}
                     onStart={() => {}}
                     timeFormat={timeFormat}
                     timeFormat={timeFormat}
+                    hasPermission={hasPermission}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>

Файловите разлики са ограничени, защото са твърде много
+ 1019 - 177
frontend/src/pages/SettingsPage.tsx


+ 6 - 2
frontend/src/pages/StatsPage.tsx

@@ -20,6 +20,7 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { FilamentTrends } from '../components/FilamentTrends';
 import { FilamentTrends } from '../components/FilamentTrends';
@@ -505,6 +506,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
 
 
 export function StatsPage() {
 export function StatsPage() {
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [isExporting, setIsExporting] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [dashboardKey, setDashboardKey] = useState(0);
   const [dashboardKey, setDashboardKey] = useState(0);
@@ -683,6 +685,8 @@ export function StatsPage() {
               setDashboardKey(prev => prev + 1);
               setDashboardKey(prev => prev + 1);
               showToast('Layout reset');
               showToast('Layout reset');
             }}
             }}
+            disabled={!hasPermission('settings:update')}
+            title={!hasPermission('settings:update') ? 'You do not have permission to reset layout' : undefined}
           >
           >
             <RotateCcw className="w-4 h-4" />
             <RotateCcw className="w-4 h-4" />
             Reset Layout
             Reset Layout
@@ -691,8 +695,8 @@ export function StatsPage() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={handleRecalculateCosts}
             onClick={handleRecalculateCosts}
-            disabled={isRecalculating}
-            title="Recalculate all archive costs using current filament prices"
+            disabled={isRecalculating || !hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to recalculate costs' : 'Recalculate all archive costs using current filament prices'}
           >
           >
             {isRecalculating ? (
             {isRecalculating ? (
               <Loader2 className="w-4 h-4 animate-spin" />
               <Loader2 className="w-4 h-4 animate-spin" />

+ 308 - 0
frontend/src/pages/StreamOverlayPage.tsx

@@ -0,0 +1,308 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useParams, useSearchParams } from 'react-router-dom';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { Layers, Clock, Timer, Printer } from 'lucide-react';
+import { api } from '../api/client';
+import type { PrinterStatus } from '../api/client';
+
+type OverlaySize = 'small' | 'medium' | 'large';
+
+interface OverlayConfig {
+  size: OverlaySize;
+  showProgress: boolean;
+  showLayers: boolean;
+  showEta: boolean;
+  showFilename: boolean;
+  showStatus: boolean;
+  showPrinter: boolean;
+}
+
+function parseConfig(params: URLSearchParams): OverlayConfig {
+  const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
+
+  return {
+    size: (params.get('size') as OverlaySize) || 'medium',
+    showProgress: show.includes('progress'),
+    showLayers: show.includes('layers'),
+    showEta: show.includes('eta'),
+    showFilename: show.includes('filename'),
+    showStatus: show.includes('status'),
+    showPrinter: show.includes('printer'),
+  };
+}
+
+function formatTime(seconds: number): string {
+  const hours = Math.floor(seconds / 3600);
+  const minutes = Math.floor((seconds % 3600) / 60);
+  return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
+}
+
+function formatETA(remainingMinutes: number): string {
+  const now = new Date();
+  const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
+  const today = new Date();
+  today.setHours(0, 0, 0, 0);
+  const etaDay = new Date(eta);
+  etaDay.setHours(0, 0, 0, 0);
+
+  const timeStr = eta.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+
+  if (etaDay.getTime() === today.getTime()) {
+    return timeStr;
+  } else if (etaDay.getTime() === today.getTime() + 86400000) {
+    return `Tomorrow ${timeStr}`;
+  } else {
+    return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
+  }
+}
+
+function getStatusText(status: PrinterStatus): string {
+  if (status.stg_cur_name) return status.stg_cur_name;
+
+  switch (status.state) {
+    case 'RUNNING': return 'Printing';
+    case 'PAUSE': return 'Paused';
+    case 'FINISH': return 'Finished';
+    case 'FAILED': return 'Failed';
+    case 'IDLE': return 'Idle';
+    default: return status.state || 'Unknown';
+  }
+}
+
+function getSizeClasses(size: OverlaySize) {
+  switch (size) {
+    case 'small':
+      return {
+        container: 'p-3',
+        text: 'text-sm',
+        textLarge: 'text-lg',
+        progressHeight: 'h-2',
+        icon: 'w-3 h-3',
+        gap: 'gap-2',
+        logoHeight: 'h-12',
+      };
+    case 'large':
+      return {
+        container: 'p-6',
+        text: 'text-xl',
+        textLarge: 'text-3xl',
+        progressHeight: 'h-4',
+        icon: 'w-6 h-6',
+        gap: 'gap-4',
+        logoHeight: 'h-24',
+      };
+    case 'medium':
+    default:
+      return {
+        container: 'p-4',
+        text: 'text-base',
+        textLarge: 'text-xl',
+        progressHeight: 'h-3',
+        icon: 'w-4 h-4',
+        gap: 'gap-3',
+        logoHeight: 'h-16',
+      };
+  }
+}
+
+export function StreamOverlayPage() {
+  const { printerId } = useParams<{ printerId: string }>();
+  const [searchParams] = useSearchParams();
+  const queryClient = useQueryClient();
+  const id = parseInt(printerId || '0', 10);
+  const [imageKey, setImageKey] = useState(Date.now());
+
+  const config = useMemo(() => parseConfig(searchParams), [searchParams]);
+  const sizes = getSizeClasses(config.size);
+
+  // Fetch printer info
+  const { data: printer } = useQuery({
+    queryKey: ['printer', id],
+    queryFn: () => api.getPrinter(id),
+    enabled: id > 0,
+  });
+
+  // Fetch printer status with polling
+  const { data: status } = useQuery({
+    queryKey: ['printerStatus', id],
+    queryFn: () => api.getPrinterStatus(id),
+    enabled: id > 0,
+    refetchInterval: 2000,
+  });
+
+  // WebSocket for real-time updates
+  useEffect(() => {
+    if (!id) return;
+
+    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+    const wsUrl = `${protocol}//${window.location.host}/api/v1/ws`;
+    const ws = new WebSocket(wsUrl);
+
+    ws.onmessage = (event) => {
+      try {
+        const data = JSON.parse(event.data);
+        if (data.type === 'printer_status' && data.printer_id === id) {
+          queryClient.setQueryData(['printerStatus', id], data.status);
+        }
+      } catch {
+        // Ignore parse errors
+      }
+    };
+
+    ws.onerror = () => {
+      // WebSocket error - polling will continue as fallback
+    };
+
+    return () => {
+      ws.close();
+    };
+  }, [id, queryClient]);
+
+  // Update document title
+  useEffect(() => {
+    document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay';
+    return () => {
+      document.title = 'Bambuddy';
+    };
+  }, [printer]);
+
+  // Refresh stream on error
+  const handleStreamError = () => {
+    setTimeout(() => {
+      setImageKey(Date.now());
+    }, 3000);
+  };
+
+  if (!id) {
+    return (
+      <div className="min-h-screen bg-black flex items-center justify-center">
+        <p className="text-white">Invalid printer ID</p>
+      </div>
+    );
+  }
+
+  if (!status) {
+    return (
+      <div className="min-h-screen bg-black flex items-center justify-center">
+        <p className="text-gray-400">Loading...</p>
+      </div>
+    );
+  }
+
+  const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
+  const progress = status.progress || 0;
+  const streamUrl = `/api/v1/printers/${id}/camera/stream?fps=10&t=${imageKey}`;
+
+  return (
+    <div className="min-h-screen bg-black relative overflow-hidden">
+      {/* Camera feed - fullscreen background */}
+      <img
+        key={imageKey}
+        src={streamUrl}
+        alt="Camera stream"
+        className="absolute inset-0 w-full h-full object-contain"
+        onError={handleStreamError}
+      />
+
+      {/* Bambuddy logo - top right */}
+      <a
+        href="https://github.com/maziggy/bambuddy"
+        target="_blank"
+        rel="noopener noreferrer"
+        className="absolute top-4 right-4 z-10"
+      >
+        <img
+          src="/img/bambuddy_logo_dark_transparent.png"
+          alt="Bambuddy"
+          className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
+        />
+      </a>
+
+      {/* Status overlay - bottom */}
+      <div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
+        <div className={`${sizes.container}`}>
+          {/* Printer name */}
+          {config.showPrinter && printer && (
+            <div className={`flex items-center ${sizes.gap} mb-2`}>
+              <Printer className={`${sizes.icon} text-white/70`} />
+              <span className={`${sizes.text} text-white font-medium`}>{printer.name}</span>
+            </div>
+          )}
+
+          {/* Filename */}
+          {config.showFilename && status.current_print && (
+            <div className={`${sizes.textLarge} text-white font-semibold mb-2 truncate drop-shadow-md`}>
+              {status.current_print.replace(/\.gcode\.3mf$|\.3mf$|\.gcode$/i, '')}
+            </div>
+          )}
+
+          {/* Status text */}
+          {config.showStatus && (
+            <div className={`${sizes.text} text-white/70 mb-2`}>
+              {getStatusText(status)}
+            </div>
+          )}
+
+          {/* Progress bar */}
+          {config.showProgress && isPrinting && (
+            <div className="mb-3">
+              <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
+                <span className="text-white/70">Progress</span>
+                <span className="text-white font-bold">{Math.round(progress)}%</span>
+              </div>
+              <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
+                <div
+                  className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
+                  style={{ width: `${progress}%` }}
+                />
+              </div>
+            </div>
+          )}
+
+          {/* Stats row */}
+          {isPrinting && (config.showLayers || config.showEta) && (
+            <div className={`flex items-center ${sizes.gap} flex-wrap`}>
+              {/* Layers */}
+              {config.showLayers && status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
+                <div className={`flex items-center ${sizes.gap} text-white/70`}>
+                  <Layers className={sizes.icon} />
+                  <span className={sizes.text}>
+                    <span className="text-white">{status.layer_num}</span>
+                    <span className="mx-1">/</span>
+                    <span>{status.total_layers}</span>
+                  </span>
+                </div>
+              )}
+
+              {/* Remaining time */}
+              {config.showEta && status.remaining_time != null && status.remaining_time > 0 && (
+                <>
+                  <div className={`flex items-center ${sizes.gap} text-white/70`}>
+                    <Timer className={sizes.icon} />
+                    <span className={`${sizes.text} text-white`}>
+                      {formatTime(status.remaining_time * 60)}
+                    </span>
+                  </div>
+
+                  <div className={`flex items-center ${sizes.gap} text-white/70`}>
+                    <Clock className={sizes.icon} />
+                    <span className={`${sizes.text} text-white`}>
+                      ETA {formatETA(status.remaining_time)}
+                    </span>
+                  </div>
+                </>
+              )}
+            </div>
+          )}
+
+          {/* Idle state */}
+          {!isPrinting && (
+            <div className={`${sizes.text} text-white/70 py-2`}>
+              {status.connected ? 'Printer is idle' : 'Printer offline'}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 310 - 101
frontend/src/pages/UsersPage.tsx

@@ -3,50 +3,73 @@ import { useNavigate } from 'react-router-dom';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
 import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { UserCreate, UserUpdate } from '../api/client';
+import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 
 
+interface FormData extends UserCreate {
+  group_ids: number[];
+  confirmPassword: string;
+}
+
 export function UsersPage() {
 export function UsersPage() {
   const navigate = useNavigate();
   const navigate = useNavigate();
-  const { user: currentUser } = useAuth();
+  const { user: currentUser, hasPermission } = useAuth();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const [showCreateModal, setShowCreateModal] = useState(false);
   const [showCreateModal, setShowCreateModal] = useState(false);
-  const [editingUser, setEditingUser] = useState<number | null>(null);
+  const [showEditModal, setShowEditModal] = useState(false);
+  const [editingUserId, setEditingUserId] = useState<number | null>(null);
   const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
   const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
-  const [formData, setFormData] = useState<UserCreate>({
+  const [formData, setFormData] = useState<FormData>({
     username: '',
     username: '',
     password: '',
     password: '',
+    confirmPassword: '',
     role: 'user',
     role: 'user',
+    group_ids: [],
   });
   });
 
 
   // Close modal on Escape key
   // Close modal on Escape key
   useEffect(() => {
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape' && showCreateModal) {
-        setShowCreateModal(false);
-        setFormData({ username: '', password: '', role: 'user' });
+      if (e.key === 'Escape') {
+        if (showCreateModal) {
+          setShowCreateModal(false);
+          setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
+        }
+        if (showEditModal) {
+          setShowEditModal(false);
+          setEditingUserId(null);
+          setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
+        }
       }
       }
     };
     };
     window.addEventListener('keydown', handleKeyDown);
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [showCreateModal]);
+  }, [showCreateModal, showEditModal]);
 
 
   const { data: users = [], isLoading } = useQuery({
   const { data: users = [], isLoading } = useQuery({
     queryKey: ['users'],
     queryKey: ['users'],
     queryFn: () => api.getUsers(),
     queryFn: () => api.getUsers(),
+    enabled: hasPermission('users:read'),
+  });
+
+  const { data: groups = [] } = useQuery({
+    queryKey: ['groups'],
+    queryFn: () => api.getGroups(),
+    enabled: hasPermission('groups:read'),
   });
   });
 
 
   const createMutation = useMutation({
   const createMutation = useMutation({
     mutationFn: (data: UserCreate) => api.createUser(data),
     mutationFn: (data: UserCreate) => api.createUser(data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['users'] });
       queryClient.invalidateQueries({ queryKey: ['users'] });
+      queryClient.invalidateQueries({ queryKey: ['groups'] });
       setShowCreateModal(false);
       setShowCreateModal(false);
-      setFormData({ username: '', password: '', role: 'user' });
+      setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
       showToast('User created successfully');
       showToast('User created successfully');
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
@@ -58,8 +81,10 @@ export function UsersPage() {
     mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
     mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['users'] });
       queryClient.invalidateQueries({ queryKey: ['users'] });
-      setEditingUser(null);
-      setFormData({ username: '', password: '', role: 'user' });
+      queryClient.invalidateQueries({ queryKey: ['groups'] });
+      setShowEditModal(false);
+      setEditingUserId(null);
+      setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
       showToast('User updated successfully');
       showToast('User updated successfully');
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
@@ -83,14 +108,39 @@ export function UsersPage() {
       showToast('Please fill in all required fields', 'error');
       showToast('Please fill in all required fields', 'error');
       return;
       return;
     }
     }
-    createMutation.mutate(formData);
+    if (formData.password !== formData.confirmPassword) {
+      showToast('Passwords do not match', 'error');
+      return;
+    }
+    if (formData.password.length < 6) {
+      showToast('Password must be at least 6 characters', 'error');
+      return;
+    }
+    createMutation.mutate({
+      username: formData.username,
+      password: formData.password,
+      role: formData.role,
+      group_ids: formData.group_ids.length > 0 ? formData.group_ids : undefined,
+    });
   };
   };
 
 
   const handleUpdate = (id: number) => {
   const handleUpdate = (id: number) => {
+    // Validate password confirmation if a new password is being set
+    if (formData.password) {
+      if (formData.password !== formData.confirmPassword) {
+        showToast('Passwords do not match', 'error');
+        return;
+      }
+      if (formData.password.length < 6) {
+        showToast('Password must be at least 6 characters', 'error');
+        return;
+      }
+    }
     const updateData: UserUpdate = {
     const updateData: UserUpdate = {
       username: formData.username || undefined,
       username: formData.username || undefined,
       password: formData.password || undefined,
       password: formData.password || undefined,
       role: formData.role,
       role: formData.role,
+      group_ids: formData.group_ids,
     };
     };
     // Remove password if empty
     // Remove password if empty
     if (!updateData.password) {
     if (!updateData.password) {
@@ -103,16 +153,34 @@ export function UsersPage() {
     setDeleteUserId(id);
     setDeleteUserId(id);
   };
   };
 
 
-  const startEdit = (user: { id: number; username: string; role: string }) => {
-    setEditingUser(user.id);
+  const startEdit = (user: UserResponse) => {
+    setEditingUserId(user.id);
     setFormData({
     setFormData({
       username: user.username,
       username: user.username,
       password: '',
       password: '',
+      confirmPassword: '',
       role: user.role,
       role: user.role,
+      group_ids: user.groups?.map(g => g.id) || [],
     });
     });
+    setShowEditModal(true);
+  };
+
+  const closeEditModal = () => {
+    setShowEditModal(false);
+    setEditingUserId(null);
+    setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
   };
   };
 
 
-  if (currentUser?.role !== 'admin') {
+  const toggleGroup = (groupId: number) => {
+    setFormData(prev => ({
+      ...prev,
+      group_ids: prev.group_ids.includes(groupId)
+        ? prev.group_ids.filter(id => id !== groupId)
+        : [...prev.group_ids, groupId],
+    }));
+  };
+
+  if (!hasPermission('users:read')) {
     return (
     return (
       <div className="p-6">
       <div className="p-6">
         <Card>
         <Card>
@@ -151,7 +219,7 @@ export function UsersPage() {
         <Button
         <Button
           onClick={() => {
           onClick={() => {
             setShowCreateModal(true);
             setShowCreateModal(true);
-            setFormData({ username: '', password: '', role: 'user' });
+            setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
           }}
           }}
         >
         >
           <Plus className="w-4 h-4" />
           <Plus className="w-4 h-4" />
@@ -173,7 +241,7 @@ export function UsersPage() {
                     Username
                     Username
                   </th>
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    Role
+                    Groups
                   </th>
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
                     Status
                     Status
@@ -187,36 +255,35 @@ export function UsersPage() {
                 {users.map((user) => (
                 {users.map((user) => (
                   <tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
                   <tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
-                      {editingUser === user.id ? (
-                        <input
-                          type="text"
-                          value={formData.username}
-                          onChange={(e) => setFormData({ ...formData, username: e.target.value })}
-                          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green"
-                        />
-                      ) : (
-                        user.username
-                      )}
+                      {user.username}
                     </td>
                     </td>
-                    <td className="px-6 py-4 whitespace-nowrap text-sm">
-                      {editingUser === user.id ? (
-                        <select
-                          value={formData.role}
-                          onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
-                          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green"
-                        >
-                          <option value="user">User</option>
-                          <option value="admin">Admin</option>
-                        </select>
-                      ) : (
-                        <span className={`px-3 py-1 rounded-full text-xs font-medium ${
-                          user.role === 'admin'
-                            ? 'bg-purple-500/20 text-purple-300'
-                            : 'bg-blue-500/20 text-blue-300'
-                        }`}>
-                          {user.role}
-                        </span>
-                      )}
+                    <td className="px-6 py-4 text-sm">
+                      <div className="flex flex-wrap gap-1">
+                        {user.is_admin && (
+                          <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300">
+                            Admin
+                          </span>
+                        )}
+                        {user.groups?.map(group => (
+                          <span
+                            key={group.id}
+                            className={`px-2 py-0.5 rounded-full text-xs font-medium ${
+                              group.name === 'Administrators'
+                                ? 'bg-purple-500/20 text-purple-300'
+                                : group.name === 'Operators'
+                                ? 'bg-blue-500/20 text-blue-300'
+                                : group.name === 'Viewers'
+                                ? 'bg-green-500/20 text-green-300'
+                                : 'bg-gray-500/20 text-gray-300'
+                            }`}
+                          >
+                            {group.name}
+                          </span>
+                        ))}
+                        {(!user.groups || user.groups.length === 0) && !user.is_admin && (
+                          <span className="text-bambu-gray">No groups</span>
+                        )}
+                      </div>
                     </td>
                     </td>
                     <td className="px-6 py-4 whitespace-nowrap text-sm">
                     <td className="px-6 py-4 whitespace-nowrap text-sm">
                       <span className={`px-3 py-1 rounded-full text-xs font-medium ${
                       <span className={`px-3 py-1 rounded-full text-xs font-medium ${
@@ -228,53 +295,26 @@ export function UsersPage() {
                       </span>
                       </span>
                     </td>
                     </td>
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
-                      {editingUser === user.id ? (
-                        <div className="flex items-center gap-2">
-                          <Button
-                            size="sm"
-                            onClick={() => handleUpdate(user.id)}
-                            disabled={updateMutation.isPending}
-                          >
-                            {updateMutation.isPending ? (
-                              <Loader2 className="w-4 h-4 animate-spin" />
-                            ) : (
-                              <Save className="w-4 h-4" />
-                            )}
-                            Save
-                          </Button>
-                          <Button
-                            size="sm"
-                            variant="secondary"
-                            onClick={() => {
-                              setEditingUser(null);
-                              setFormData({ username: '', password: '', role: 'user' });
-                            }}
-                          >
-                            Cancel
-                          </Button>
-                        </div>
-                      ) : (
-                        <div className="flex items-center gap-2">
+                      <div className="flex items-center gap-2">
+                        <Button
+                          size="sm"
+                          variant="ghost"
+                          onClick={() => startEdit(user)}
+                        >
+                          <Edit2 className="w-4 h-4" />
+                          Edit
+                        </Button>
+                        {user.id !== currentUser?.id && (
                           <Button
                           <Button
                             size="sm"
                             size="sm"
                             variant="ghost"
                             variant="ghost"
-                            onClick={() => startEdit(user)}
+                            onClick={() => handleDelete(user.id)}
                           >
                           >
-                            <Edit2 className="w-4 h-4" />
-                            Edit
+                            <Trash2 className="w-4 h-4" />
+                            Delete
                           </Button>
                           </Button>
-                          {user.id !== currentUser?.id && (
-                            <Button
-                              size="sm"
-                              variant="ghost"
-                              onClick={() => handleDelete(user.id)}
-                            >
-                              <Trash2 className="w-4 h-4" />
-                              Delete
-                            </Button>
-                          )}
-                        </div>
-                      )}
+                        )}
+                      </div>
                     </td>
                     </td>
                   </tr>
                   </tr>
                 ))}
                 ))}
@@ -290,7 +330,7 @@ export function UsersPage() {
           className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
           className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
           onClick={() => {
           onClick={() => {
             setShowCreateModal(false);
             setShowCreateModal(false);
-            setFormData({ username: '', password: '', role: 'user' });
+            setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
           }}
           }}
         >
         >
           <Card
           <Card
@@ -308,7 +348,7 @@ export function UsersPage() {
                   size="sm"
                   size="sm"
                   onClick={() => {
                   onClick={() => {
                     setShowCreateModal(false);
                     setShowCreateModal(false);
-                    setFormData({ username: '', password: '', role: 'user' });
+                    setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
                   }}
                   }}
                 >
                 >
                   <X className="w-5 h-5" />
                   <X className="w-5 h-5" />
@@ -346,16 +386,51 @@ export function UsersPage() {
                 </div>
                 </div>
                 <div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
                   <label className="block text-sm font-medium text-white mb-2">
-                    Role
+                    Confirm Password
                   </label>
                   </label>
-                  <select
-                    value={formData.role}
-                    onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
-                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                  >
-                    <option value="user">User</option>
-                    <option value="admin">Admin</option>
-                  </select>
+                  <input
+                    type="password"
+                    value={formData.confirmPassword}
+                    onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
+                    className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
+                      formData.confirmPassword && formData.password !== formData.confirmPassword
+                        ? 'border-red-500'
+                        : 'border-bambu-dark-tertiary'
+                    }`}
+                    placeholder="Confirm password"
+                    autoComplete="new-password"
+                    minLength={6}
+                  />
+                  {formData.confirmPassword && formData.password !== formData.confirmPassword && (
+                    <p className="text-red-400 text-xs mt-1">Passwords do not match</p>
+                  )}
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Groups
+                  </label>
+                  <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+                    {groups.map(group => (
+                      <label
+                        key={group.id}
+                        className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
+                      >
+                        <input
+                          type="checkbox"
+                          checked={formData.group_ids.includes(group.id)}
+                          onChange={() => toggleGroup(group.id)}
+                          className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
+                        />
+                        <span className="text-sm text-white">{group.name}</span>
+                        {group.is_system && (
+                          <span className="text-xs text-yellow-400">(System)</span>
+                        )}
+                      </label>
+                    ))}
+                    {groups.length === 0 && (
+                      <p className="text-sm text-bambu-gray">No groups available</p>
+                    )}
+                  </div>
                 </div>
                 </div>
               </div>
               </div>
               <div className="mt-6 flex justify-end gap-3">
               <div className="mt-6 flex justify-end gap-3">
@@ -363,14 +438,14 @@ export function UsersPage() {
                   variant="secondary"
                   variant="secondary"
                   onClick={() => {
                   onClick={() => {
                     setShowCreateModal(false);
                     setShowCreateModal(false);
-                    setFormData({ username: '', password: '', role: 'user' });
+                    setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
                   }}
                   }}
                 >
                 >
                   Cancel
                   Cancel
                 </Button>
                 </Button>
                 <Button
                 <Button
                   onClick={handleCreate}
                   onClick={handleCreate}
-                  disabled={createMutation.isPending || !formData.username || !formData.password}
+                  disabled={createMutation.isPending || !formData.username || !formData.password || formData.password !== formData.confirmPassword || formData.password.length < 6}
                 >
                 >
                   {createMutation.isPending ? (
                   {createMutation.isPending ? (
                     <>
                     <>
@@ -390,6 +465,140 @@ export function UsersPage() {
         </div>
         </div>
       )}
       )}
 
 
+      {/* Edit User Modal */}
+      {showEditModal && editingUserId !== null && (
+        <div
+          className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+          onClick={closeEditModal}
+        >
+          <Card
+            className="w-full max-w-md"
+            onClick={(e: React.MouseEvent) => e.stopPropagation()}
+          >
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Edit2 className="w-5 h-5 text-bambu-green" />
+                  <h2 className="text-lg font-semibold text-white">Edit User</h2>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={closeEditModal}
+                >
+                  <X className="w-5 h-5" />
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div className="space-y-4">
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Username
+                  </label>
+                  <input
+                    type="text"
+                    value={formData.username}
+                    onChange={(e) => setFormData({ ...formData, username: e.target.value })}
+                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                    placeholder="Enter username"
+                    autoComplete="username"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Password <span className="text-bambu-gray font-normal">(leave blank to keep current)</span>
+                  </label>
+                  <input
+                    type="password"
+                    value={formData.password}
+                    onChange={(e) => setFormData({ ...formData, password: e.target.value, confirmPassword: '' })}
+                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                    placeholder="Enter new password"
+                    autoComplete="new-password"
+                    minLength={6}
+                  />
+                </div>
+                {formData.password && (
+                  <div>
+                    <label className="block text-sm font-medium text-white mb-2">
+                      Confirm Password
+                    </label>
+                    <input
+                      type="password"
+                      value={formData.confirmPassword}
+                      onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
+                      className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
+                        formData.confirmPassword && formData.password !== formData.confirmPassword
+                          ? 'border-red-500'
+                          : 'border-bambu-dark-tertiary'
+                      }`}
+                      placeholder="Confirm new password"
+                      autoComplete="new-password"
+                      minLength={6}
+                    />
+                    {formData.confirmPassword && formData.password !== formData.confirmPassword && (
+                      <p className="text-red-400 text-xs mt-1">Passwords do not match</p>
+                    )}
+                  </div>
+                )}
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Groups
+                  </label>
+                  <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+                    {groups.map(group => (
+                      <label
+                        key={group.id}
+                        className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
+                      >
+                        <input
+                          type="checkbox"
+                          checked={formData.group_ids.includes(group.id)}
+                          onChange={() => toggleGroup(group.id)}
+                          className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
+                        />
+                        <span className="text-sm text-white">{group.name}</span>
+                        {group.is_system && (
+                          <span className="text-xs text-yellow-400">(System)</span>
+                        )}
+                      </label>
+                    ))}
+                    {groups.length === 0 && (
+                      <p className="text-sm text-bambu-gray">No groups available</p>
+                    )}
+                  </div>
+                </div>
+              </div>
+              <div className="mt-6 flex justify-end gap-3">
+                <Button
+                  variant="secondary"
+                  onClick={closeEditModal}
+                >
+                  Cancel
+                </Button>
+                <Button
+                  onClick={() => handleUpdate(editingUserId)}
+                  disabled={updateMutation.isPending || !formData.username || !!(formData.password && (formData.password !== formData.confirmPassword || formData.password.length < 6))}
+                >
+                  {updateMutation.isPending ? (
+                    <>
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      Saving...
+                    </>
+                  ) : (
+                    <>
+                      <Save className="w-4 h-4" />
+                      Save Changes
+                    </>
+                  )}
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+
       {/* Delete Confirmation Modal */}
       {/* Delete Confirmation Modal */}
       {deleteUserId !== null && (
       {deleteUserId !== null && (
         <ConfirmModal
         <ConfirmModal

+ 234 - 0
install/README.md

@@ -0,0 +1,234 @@
+# BamBuddy Installation Scripts
+
+Interactive installation scripts for BamBuddy with support for both native and Docker deployments.
+
+## Quick Start
+
+### Docker Installation (Recommended)
+
+**Linux/macOS:**
+```bash
+curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash
+```
+
+### Native Installation
+
+**Linux/macOS:**
+```bash
+curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash
+```
+
+---
+
+## Scripts Overview
+
+| Script | Platform | Method |
+|--------|----------|--------|
+| `install.sh` | Linux, macOS | Native (Python venv) |
+| `docker-install.sh` | Linux, macOS | Docker |
+
+---
+
+## Native Installation Scripts
+
+### `install.sh` (Linux/macOS)
+
+Installs BamBuddy with Python virtual environment and optional systemd/launchd service.
+
+**Supported Systems:**
+- Debian/Ubuntu (apt)
+- RHEL/Fedora/CentOS (dnf/yum)
+- Arch Linux (pacman)
+- openSUSE (zypper)
+- macOS (Homebrew)
+
+**Options:**
+```
+--path PATH        Installation directory (default: /opt/bambuddy)
+--port PORT        Port to listen on (default: 8000)
+--tz TIMEZONE      Timezone (default: system timezone)
+--data-dir PATH    Data directory (default: INSTALL_PATH/data)
+--log-dir PATH     Log directory (default: INSTALL_PATH/logs)
+--debug            Enable debug mode
+--log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
+--no-service       Skip systemd/launchd service setup
+--yes, -y          Non-interactive mode, accept defaults
+```
+
+**Examples:**
+```bash
+# Interactive installation
+./install.sh
+
+# Unattended with custom settings
+./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes
+
+# Minimal unattended
+./install.sh -y
+
+# Skip service setup
+./install.sh --no-service -y
+```
+
+---
+
+## Docker Installation Scripts
+
+### `docker-install.sh` (Linux/macOS)
+
+Installs BamBuddy using Docker containers.
+
+**Options:**
+```
+--path PATH        Installation directory (default: ~/bambuddy)
+--port PORT        Port to expose (default: 8000)
+--tz TIMEZONE      Timezone (default: system timezone)
+--build            Build from source instead of using pre-built image
+--yes, -y          Non-interactive mode, accept defaults
+```
+
+**Examples:**
+```bash
+# Interactive installation
+./docker-install.sh
+
+# Unattended with custom settings
+./docker-install.sh --path /srv/bambuddy --port 3000 --tz Europe/Berlin --yes
+
+# Build from source
+./docker-install.sh --build --yes
+```
+
+---
+
+## Configuration Options
+
+All scripts support these configuration options:
+
+| Option | Description | Default |
+|--------|-------------|---------|
+| Install Path | Where BamBuddy is installed | `/opt/bambuddy` (Linux/Docker) |
+| Port | HTTP port for web interface | `8000` |
+| Timezone | Server timezone | System timezone or `UTC` |
+| Data Directory | Database and archives | `INSTALL_PATH/data` |
+| Log Directory | Application logs | `INSTALL_PATH/logs` |
+| Debug Mode | Enable verbose logging | `false` |
+| Log Level | INFO, WARNING, ERROR, DEBUG | `INFO` |
+
+---
+
+## Post-Installation
+
+### Accessing BamBuddy
+
+After installation, open your browser to:
+```
+http://localhost:8000
+```
+
+Or use the port you specified during installation.
+
+### Service Management
+
+**Linux (systemd):**
+```bash
+sudo systemctl status bambuddy    # Check status
+sudo systemctl start bambuddy     # Start
+sudo systemctl stop bambuddy      # Stop
+sudo systemctl restart bambuddy   # Restart
+sudo journalctl -u bambuddy -f    # View logs
+```
+
+**macOS (launchd):**
+```bash
+launchctl list | grep bambuddy                              # Check status
+launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist    # Start
+launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist  # Stop
+```
+
+**Docker:**
+```bash
+docker compose ps           # Check status
+docker compose up -d        # Start
+docker compose down         # Stop
+docker compose restart      # Restart
+docker compose logs -f      # View logs
+```
+
+### Updating
+
+**Native installation:**
+```bash
+cd /opt/bambuddy
+git pull
+source venv/bin/activate
+pip install -r requirements.txt
+cd frontend && npm ci && npm run build
+sudo systemctl restart bambuddy  # Linux
+```
+
+**Docker (pre-built image):**
+```bash
+cd ~/bambuddy
+docker compose pull
+docker compose up -d
+```
+
+**Docker (from source):**
+```bash
+cd ~/bambuddy
+git pull
+docker compose up -d --build
+```
+
+---
+
+## Troubleshooting
+
+### Permission Denied (Linux)
+Run with `sudo` or ensure your user has appropriate permissions:
+```bash
+sudo ./install.sh
+```
+
+### Docker: Printer Discovery Not Working
+Docker Desktop for macOS doesn't support host networking. Add printers manually by IP address in the BamBuddy web interface.
+
+### Service Won't Start
+Check logs for errors:
+```bash
+# Linux
+sudo journalctl -u bambuddy -n 50
+
+# Docker
+docker compose logs bambuddy
+```
+
+### Port Already in Use
+Choose a different port during installation or stop the conflicting service:
+```bash
+# Find what's using port 8000
+sudo lsof -i :8000  # Linux/macOS
+```
+
+---
+
+## Requirements
+
+### Native Installation
+- Python 3.10+ (automatically installed if missing)
+- Node.js 18+ (automatically installed if missing)
+- Git (automatically installed if missing)
+- ~500MB disk space
+
+### Docker Installation
+- Docker Engine 20+ or Docker Desktop
+- ~1GB disk space (includes image)
+
+---
+
+## Support
+
+- **Documentation:** https://wiki.bambuddy.cool
+- **Discord:** https://discord.gg/aFS3ZfScHM
+- **Issues:** https://github.com/maziggy/bambuddy/issues

+ 541 - 0
install/docker-install.sh

@@ -0,0 +1,541 @@
+#!/usr/bin/env bash
+#
+# BamBuddy Docker Installation Script
+# Supports: Linux (all distros), macOS
+#
+# Usage:
+#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash
+#   Unattended:   ./docker-install.sh --path /opt/bambuddy --port 8000 --yes
+#
+# Options:
+#   --path PATH        Installation directory (default: /opt/bambuddy)
+#   --port PORT        Port to expose (default: 8000)
+#   --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
+#   --tz TIMEZONE      Timezone (default: system timezone or UTC)
+#   --build            Build from source instead of using pre-built image
+#   --yes, -y          Non-interactive mode, accept defaults
+#   --help, -h         Show this help message
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+# Default values
+DEFAULT_INSTALL_PATH="/opt/bambuddy"
+DEFAULT_PORT="8000"
+DEFAULT_BIND_ADDRESS="0.0.0.0"
+
+# Script variables
+INSTALL_PATH=""
+PORT=""
+BIND_ADDRESS=""
+TIMEZONE=""
+BUILD_FROM_SOURCE="false"
+NON_INTERACTIVE="false"
+OS_TYPE=""
+DOCKER_CMD=""
+
+# -----------------------------------------------------------------------------
+# Helper Functions
+# -----------------------------------------------------------------------------
+
+print_banner() {
+    echo -e "${CYAN}"
+    echo "╔════════════════════════════════════════════════════════╗"
+    echo "║                                                        ║"
+    echo "║   ____                  _               _     _        ║"
+    echo "║  | __ )  __ _ _ __ ___ | |__  _   _  __| | __| |_   _  ║"
+    echo "║  |  _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║"
+    echo "║  | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║"
+    echo "║  |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║"
+    echo "║                                                 |___/  ║"
+    echo "║                                                        ║"
+    echo "║            Docker Installation Script                  ║"
+    echo "║                                                        ║"
+    echo "╚════════════════════════════════════════════════════════╝"
+    echo -e "${NC}"
+}
+
+log_info() {
+    echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+log_success() {
+    echo -e "${GREEN}[OK]${NC} $1"
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+prompt() {
+    local prompt_text="$1"
+    local default_value="$2"
+    local var_name="$3"
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        eval "$var_name=\"$default_value\""
+        return
+    fi
+
+    if [[ -n "$default_value" ]]; then
+        echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
+    else
+        echo -en "${BOLD}$prompt_text${NC}: "
+    fi
+
+    read -r input
+    if [[ -z "$input" ]]; then
+        eval "$var_name=\"$default_value\""
+    else
+        eval "$var_name=\"$input\""
+    fi
+}
+
+prompt_yes_no() {
+    local prompt_text="$1"
+    local default="$2"  # y or n
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        [[ "$default" == "y" ]] && return 0 || return 1
+    fi
+
+    local yn_hint="[y/n]"
+    [[ "$default" == "y" ]] && yn_hint="[Y/n]"
+    [[ "$default" == "n" ]] && yn_hint="[y/N]"
+
+    while true; do
+        echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
+        read -r yn
+        [[ -z "$yn" ]] && yn="$default"
+        case "$yn" in
+            [Yy]* ) return 0;;
+            [Nn]* ) return 1;;
+            * ) echo "Please answer yes or no.";;
+        esac
+    done
+}
+
+show_help() {
+    echo "BamBuddy Docker Installation Script"
+    echo ""
+    echo "Usage: $0 [OPTIONS]"
+    echo ""
+    echo "Options:"
+    echo "  --path PATH        Installation directory (default: /opt/bambuddy)"
+    echo "  --port PORT        Port to expose (default: 8000)"
+    echo "  --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
+    echo "  --tz TIMEZONE      Timezone (default: system timezone or UTC)"
+    echo "  --build            Build from source instead of using pre-built image"
+    echo "  --yes, -y          Non-interactive mode, accept defaults"
+    echo "  --help, -h         Show this help message"
+    echo ""
+    echo "Examples:"
+    echo "  Interactive installation:"
+    echo "    ./docker-install.sh"
+    echo ""
+    echo "  Unattended installation with custom settings:"
+    echo "    ./docker-install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
+    echo ""
+    echo "  Build from source:"
+    echo "    ./docker-install.sh --build --yes"
+    exit 0
+}
+
+# -----------------------------------------------------------------------------
+# System Detection
+# -----------------------------------------------------------------------------
+
+detect_os() {
+    if [[ "$OSTYPE" == "darwin"* ]]; then
+        OS_TYPE="macos"
+        return
+    fi
+
+    if [[ -f /etc/os-release ]]; then
+        OS_TYPE="linux"
+    else
+        log_error "Cannot detect operating system"
+        exit 1
+    fi
+}
+
+detect_docker() {
+    # Check for docker compose (v2) or docker-compose (v1)
+    if docker compose version &>/dev/null 2>&1; then
+        DOCKER_CMD="docker compose"
+        log_success "Found Docker Compose v2"
+        return 0
+    elif docker-compose --version &>/dev/null 2>&1; then
+        DOCKER_CMD="docker-compose"
+        log_success "Found Docker Compose v1"
+        return 0
+    fi
+    return 1
+}
+
+detect_timezone() {
+    if [[ -n "$TIMEZONE" ]]; then
+        return 0
+    fi
+
+    # Try to get system timezone (with error handling for set -e)
+    TIMEZONE=""
+    if [[ -f /etc/timezone ]]; then
+        TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
+        TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
+        TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+    fi
+
+    # Default to UTC if not found (use if/then to avoid set -e issue with &&)
+    if [[ -z "$TIMEZONE" ]]; then
+        TIMEZONE="UTC"
+    fi
+    return 0
+}
+
+# -----------------------------------------------------------------------------
+# Installation Functions
+# -----------------------------------------------------------------------------
+
+install_docker() {
+    log_info "Docker not found, installing..."
+
+    case "$OS_TYPE" in
+        linux)
+            # Use Docker's convenience script
+            curl -fsSL https://get.docker.com | sh
+
+            # Add current user to docker group
+            if [[ -n "$SUDO_USER" ]]; then
+                sudo usermod -aG docker "$SUDO_USER"
+                log_warn "Added $SUDO_USER to docker group. You may need to log out and back in."
+            else
+                sudo usermod -aG docker "$USER"
+                log_warn "Added $USER to docker group. You may need to log out and back in."
+            fi
+
+            # Start Docker service
+            sudo systemctl enable docker
+            sudo systemctl start docker
+            ;;
+        macos)
+            log_error "Docker Desktop not found."
+            log_error "Please install Docker Desktop for Mac from: https://www.docker.com/products/docker-desktop"
+            exit 1
+            ;;
+    esac
+
+    log_success "Docker installed"
+}
+
+create_install_dir() {
+    log_info "Creating installation directory..."
+
+    mkdir -p "$INSTALL_PATH"
+    cd "$INSTALL_PATH"
+
+    log_success "Directory created: $INSTALL_PATH"
+}
+
+download_compose_file() {
+    log_info "Downloading docker-compose.yml..."
+
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        # Clone the full repo for building
+        if [[ -d ".git" ]]; then
+            log_info "Existing repository found, updating..."
+            git fetch origin
+            git reset --hard origin/main
+        else
+            git clone https://github.com/maziggy/bambuddy.git .
+        fi
+    else
+        # Just download the compose file
+        curl -fsSL -o docker-compose.yml \
+            https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml
+    fi
+
+    log_success "docker-compose.yml ready"
+}
+
+create_env_file() {
+    log_info "Creating environment configuration..."
+
+    cat > .env << EOF
+# BamBuddy Docker Configuration
+# Generated by docker-install.sh on $(date)
+
+# Port BamBuddy runs on
+PORT=$PORT
+
+# Timezone
+TZ=$TIMEZONE
+EOF
+
+    log_success "Environment file created"
+}
+
+customize_compose() {
+    # Detect if we need to disable host networking (macOS/Windows in Docker Desktop)
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        log_warn "Docker Desktop detected. Host networking is not supported."
+        log_info "Modifying docker-compose.yml for port mapping..."
+
+        # Create a modified compose file for macOS
+        if [[ -f docker-compose.yml ]]; then
+            # Comment out network_mode: host and uncomment ports section
+            sed -i.bak \
+                -e 's/^[[:space:]]*network_mode: host/#    network_mode: host/' \
+                -e 's/^[[:space:]]*#ports:/    ports:/' \
+                -e 's/^[[:space:]]*#[[:space:]]*- "\${PORT:-8000}:8000"/      - "\${PORT:-8000}:8000"/' \
+                docker-compose.yml
+
+            log_warn "Printer discovery may not work. Add printers manually by IP address."
+        fi
+    fi
+}
+
+start_container() {
+    log_info "Starting BamBuddy..."
+
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        $DOCKER_CMD up -d --build
+    else
+        $DOCKER_CMD up -d
+    fi
+
+    # Wait for container to start
+    log_info "Waiting for container to start..."
+    local max_attempts=15
+    local attempt=0
+
+    while [[ $attempt -lt $max_attempts ]]; do
+        # Check if container is running (Up)
+        if $DOCKER_CMD ps | grep -q "Up"; then
+            log_success "BamBuddy container is running"
+            return 0
+        fi
+
+        # Check if container failed
+        if $DOCKER_CMD ps -a | grep -q "Exited"; then
+            log_error "Container failed to start"
+            log_info "Check logs with: $DOCKER_CMD logs bambuddy"
+            return 1
+        fi
+
+        sleep 2
+        ((attempt++))
+    done
+
+    log_warn "Container may still be starting. Check with: $DOCKER_CMD ps"
+}
+
+# -----------------------------------------------------------------------------
+# Main Installation Flow
+# -----------------------------------------------------------------------------
+
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --path)
+                INSTALL_PATH="$2"
+                shift 2
+                ;;
+            --port)
+                PORT="$2"
+                shift 2
+                ;;
+            --bind)
+                BIND_ADDRESS="$2"
+                shift 2
+                ;;
+            --tz)
+                TIMEZONE="$2"
+                shift 2
+                ;;
+            --build)
+                BUILD_FROM_SOURCE="true"
+                shift
+                ;;
+            --yes|-y)
+                NON_INTERACTIVE="true"
+                shift
+                ;;
+            --help|-h)
+                show_help
+                ;;
+            *)
+                log_error "Unknown option: $1"
+                show_help
+                ;;
+        esac
+    done
+}
+
+gather_config() {
+    echo ""
+    echo -e "${BOLD}Installation Configuration${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    # Installation path
+    [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
+
+    # Port
+    [[ -z "$PORT" ]] && prompt "Port to expose" "$DEFAULT_PORT" PORT
+
+    # Bind address
+    if [[ -z "$BIND_ADDRESS" ]]; then
+        echo ""
+        echo "Network access:"
+        echo "  0.0.0.0   - Accessible from other devices on your network (recommended)"
+        echo "  127.0.0.1 - Only accessible from this machine"
+        prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
+    fi
+
+    # Timezone
+    detect_timezone
+    prompt "Timezone" "$TIMEZONE" TIMEZONE
+
+    # Build from source?
+    if [[ "$BUILD_FROM_SOURCE" != "true" ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        if prompt_yes_no "Build from source? (No = use pre-built image)" "n"; then
+            BUILD_FROM_SOURCE="true"
+        fi
+    fi
+
+    # Confirm
+    echo ""
+    echo -e "${BOLD}Installation Summary${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo -e "  Install path:  ${GREEN}$INSTALL_PATH${NC}"
+    echo -e "  Port:          ${GREEN}$PORT${NC}"
+    echo -e "  Bind address:  ${GREEN}$BIND_ADDRESS${NC}"
+    echo -e "  Timezone:      ${GREEN}$TIMEZONE${NC}"
+    echo -e "  Build source:  ${GREEN}$BUILD_FROM_SOURCE${NC}"
+    echo ""
+
+    if ! prompt_yes_no "Proceed with installation?" "y"; then
+        echo "Installation cancelled."
+        exit 0
+    fi
+}
+
+main() {
+    parse_args "$@"
+    print_banner
+
+    # Check if running via pipe (curl | bash) - interactive mode won't work
+    if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        log_error "Interactive mode requires a terminal."
+        log_info "When using 'curl | bash', you must use non-interactive mode:"
+        echo ""
+        echo "    curl -fsSL URL | bash -s -- --yes"
+        echo ""
+        log_info "Or download and run directly:"
+        echo ""
+        echo "    curl -fsSL URL -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh"
+        echo ""
+        exit 1
+    fi
+
+    # Detect system
+    log_info "Detecting system..."
+    detect_os
+    log_success "Detected: $OS_TYPE"
+
+    # Check for Docker
+    if ! command -v docker &>/dev/null; then
+        install_docker
+    fi
+
+    if ! detect_docker; then
+        log_error "Docker Compose not found. Please install Docker Compose."
+        exit 1
+    fi
+
+    # Check if Docker daemon is running
+    if ! docker info &>/dev/null; then
+        log_error "Docker daemon is not running. Please start Docker and try again."
+        exit 1
+    fi
+
+    # Gather configuration
+    gather_config
+
+    # Install steps
+    echo ""
+    echo -e "${BOLD}Starting Installation${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    create_install_dir
+    download_compose_file
+    create_env_file
+    customize_compose
+    start_container
+
+    # Done!
+    echo ""
+    echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}║              Installation Complete!                          ║${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
+    echo ""
+    # Show appropriate URL based on bind address
+    if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
+        local ip_addr
+        ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+        echo -e "                    ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
+    else
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Manage container:${NC}"
+    echo -e "    Status:  cd $INSTALL_PATH && $DOCKER_CMD ps"
+    echo -e "    Logs:    cd $INSTALL_PATH && $DOCKER_CMD logs -f bambuddy"
+    echo -e "    Stop:    cd $INSTALL_PATH && $DOCKER_CMD down"
+    echo -e "    Start:   cd $INSTALL_PATH && $DOCKER_CMD up -d"
+    echo -e "    Restart: cd $INSTALL_PATH && $DOCKER_CMD restart"
+    echo ""
+    echo -e "  ${BOLD}Update BamBuddy:${NC}"
+    if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then
+        echo -e "    cd $INSTALL_PATH && git pull && $DOCKER_CMD up -d --build"
+    else
+        echo -e "    cd $INSTALL_PATH && $DOCKER_CMD pull && $DOCKER_CMD up -d"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Data location:${NC}  Docker volumes (bambuddy_data, bambuddy_logs)"
+    echo ""
+    echo -e "  ${BOLD}Documentation:${NC}  ${CYAN}https://wiki.bambuddy.cool${NC}"
+    echo ""
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        echo -e "  ${YELLOW}Note:${NC} Printer discovery may not work with Docker Desktop."
+        echo -e "        Add printers manually using their IP address."
+        echo ""
+    fi
+}
+
+main "$@"

+ 883 - 0
install/install.sh

@@ -0,0 +1,883 @@
+#!/usr/bin/env bash
+#
+# BamBuddy Native Installation Script
+# Supports: Debian/Ubuntu, RHEL/Fedora/CentOS, Arch Linux, macOS
+#
+# Usage:
+#   Interactive:  curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash
+#   Unattended:   ./install.sh --path /opt/bambuddy --port 8000 --yes
+#
+# Options:
+#   --path PATH        Installation directory (default: /opt/bambuddy)
+#   --port PORT        Port to listen on (default: 8000)
+#   --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)
+#   --tz TIMEZONE      Timezone (default: system timezone or UTC)
+#   --data-dir PATH    Data directory (default: INSTALL_PATH/data)
+#   --log-dir PATH     Log directory (default: INSTALL_PATH/logs)
+#   --debug            Enable debug mode
+#   --log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)
+#   --no-service       Skip systemd service setup (Linux only)
+#   --set-system-tz    Set system timezone to match (for unattended installs)
+#   --yes, -y          Non-interactive mode, accept defaults
+#   --help, -h         Show this help message
+#
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+CYAN='\033[0;36m'
+NC='\033[0m' # No Color
+BOLD='\033[1m'
+
+# Default values
+DEFAULT_INSTALL_PATH="/opt/bambuddy"
+DEFAULT_PORT="8000"
+DEFAULT_BIND_ADDRESS="0.0.0.0"
+DEFAULT_LOG_LEVEL="INFO"
+DEFAULT_DEBUG="false"
+
+# Script variables
+INSTALL_PATH=""
+PORT=""
+BIND_ADDRESS=""
+TIMEZONE=""
+DATA_DIR=""
+LOG_DIR=""
+DEBUG_MODE=""
+LOG_LEVEL=""
+SKIP_SERVICE="false"
+SET_SYSTEM_TZ=""
+NON_INTERACTIVE="false"
+OS_TYPE=""
+PKG_MANAGER=""
+PYTHON_CMD=""
+SERVICE_USER="bambuddy"
+
+# -----------------------------------------------------------------------------
+# Helper Functions
+# -----------------------------------------------------------------------------
+
+print_banner() {
+    echo -e "${CYAN}"
+    echo "╔════════════════════════════════════════════════════════╗"
+    echo "║                                                        ║"
+    echo "║   ____                  _               _     _        ║"
+    echo "║  | __ )  __ _ _ __ ___ | |__  _   _  __| | __| |_   _  ║"
+    echo "║  |  _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║"
+    echo "║  | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║"
+    echo "║  |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║"
+    echo "║                                                 |___/  ║"
+    echo "║                                                        ║"
+    echo "║            Native Installation Script                  ║"
+    echo "║                                                        ║"
+    echo "╚════════════════════════════════════════════════════════╝"
+    echo -e "${NC}"
+}
+
+log_info() {
+    echo -e "${BLUE}[INFO]${NC} $1"
+}
+
+log_success() {
+    echo -e "${GREEN}[OK]${NC} $1"
+}
+
+log_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+prompt() {
+    local prompt_text="$1"
+    local default_value="$2"
+    local var_name="$3"
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        eval "$var_name=\"$default_value\""
+        return
+    fi
+
+    if [[ -n "$default_value" ]]; then
+        echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: "
+    else
+        echo -en "${BOLD}$prompt_text${NC}: "
+    fi
+
+    read -r input
+    if [[ -z "$input" ]]; then
+        eval "$var_name=\"$default_value\""
+    else
+        eval "$var_name=\"$input\""
+    fi
+}
+
+prompt_yes_no() {
+    local prompt_text="$1"
+    local default="$2"  # y or n
+
+    if [[ "$NON_INTERACTIVE" == "true" ]]; then
+        [[ "$default" == "y" ]] && return 0 || return 1
+    fi
+
+    local yn_hint="[y/n]"
+    [[ "$default" == "y" ]] && yn_hint="[Y/n]"
+    [[ "$default" == "n" ]] && yn_hint="[y/N]"
+
+    while true; do
+        echo -en "${BOLD}$prompt_text${NC} $yn_hint: "
+        read -r yn
+        [[ -z "$yn" ]] && yn="$default"
+        case "$yn" in
+            [Yy]* ) return 0;;
+            [Nn]* ) return 1;;
+            * ) echo "Please answer yes or no.";;
+        esac
+    done
+}
+
+show_help() {
+    echo "BamBuddy Native Installation Script"
+    echo ""
+    echo "Usage: $0 [OPTIONS]"
+    echo ""
+    echo "Options:"
+    echo "  --path PATH        Installation directory (default: /opt/bambuddy)"
+    echo "  --port PORT        Port to listen on (default: 8000)"
+    echo "  --bind ADDRESS     Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)"
+    echo "  --tz TIMEZONE      Timezone (default: system timezone or UTC)"
+    echo "  --data-dir PATH    Data directory (default: INSTALL_PATH/data)"
+    echo "  --log-dir PATH     Log directory (default: INSTALL_PATH/logs)"
+    echo "  --debug            Enable debug mode"
+    echo "  --log-level LEVEL  Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)"
+    echo "  --no-service       Skip systemd service setup (Linux only)"
+    echo "  --set-system-tz    Set system timezone to match (for unattended installs)"
+    echo "  --yes, -y          Non-interactive mode, accept defaults"
+    echo "  --help, -h         Show this help message"
+    echo ""
+    echo "Examples:"
+    echo "  Interactive installation:"
+    echo "    ./install.sh"
+    echo ""
+    echo "  Unattended installation with custom settings:"
+    echo "    ./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes"
+    echo ""
+    echo "  Minimal unattended installation:"
+    echo "    ./install.sh -y"
+    exit 0
+}
+
+# -----------------------------------------------------------------------------
+# System Detection
+# -----------------------------------------------------------------------------
+
+detect_os() {
+    if [[ "$OSTYPE" == "darwin"* ]]; then
+        OS_TYPE="macos"
+        PKG_MANAGER="brew"
+        return
+    fi
+
+    if [[ -f /etc/os-release ]]; then
+        . /etc/os-release
+        case "$ID" in
+            ubuntu|debian|raspbian|linuxmint|pop)
+                OS_TYPE="debian"
+                PKG_MANAGER="apt"
+                ;;
+            fedora|rhel|centos|rocky|almalinux|ol)
+                OS_TYPE="rhel"
+                if command -v dnf &>/dev/null; then
+                    PKG_MANAGER="dnf"
+                else
+                    PKG_MANAGER="yum"
+                fi
+                ;;
+            arch|manjaro|endeavouros)
+                OS_TYPE="arch"
+                PKG_MANAGER="pacman"
+                ;;
+            opensuse*|sles)
+                OS_TYPE="suse"
+                PKG_MANAGER="zypper"
+                ;;
+            *)
+                log_error "Unsupported Linux distribution: $ID"
+                exit 1
+                ;;
+        esac
+    else
+        log_error "Cannot detect operating system"
+        exit 1
+    fi
+}
+
+detect_python() {
+    # Try python3 first, then python
+    if command -v python3 &>/dev/null; then
+        PYTHON_CMD="python3"
+    elif command -v python &>/dev/null; then
+        local version
+        version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1)
+        if [[ "$version" -ge 3 ]]; then
+            PYTHON_CMD="python"
+        fi
+    fi
+
+    if [[ -z "$PYTHON_CMD" ]]; then
+        return 1
+    fi
+
+    # Check version >= 3.10
+    local version
+    version=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
+    local major minor
+    major=$(echo "$version" | cut -d'.' -f1)
+    minor=$(echo "$version" | cut -d'.' -f2)
+
+    if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
+        log_warn "Python $version found, but 3.10+ is required"
+        return 1
+    fi
+
+    log_success "Found Python $version"
+    return 0
+}
+
+detect_timezone() {
+    if [[ -n "$TIMEZONE" ]]; then
+        return 0
+    fi
+
+    # Try to get system timezone (with error handling for set -e)
+    TIMEZONE=""
+    if [[ -f /etc/timezone ]]; then
+        TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then
+        TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true
+    fi
+
+    if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then
+        TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+    fi
+
+    # Default to UTC if not found (use if/then to avoid set -e issue with &&)
+    if [[ -z "$TIMEZONE" ]]; then
+        TIMEZONE="UTC"
+    fi
+    return 0
+}
+
+# -----------------------------------------------------------------------------
+# Package Installation
+# -----------------------------------------------------------------------------
+
+install_dependencies() {
+    log_info "Installing system dependencies..."
+
+    case "$PKG_MANAGER" in
+        apt)
+            sudo apt-get update
+            sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg
+            ;;
+        dnf|yum)
+            sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg
+            ;;
+        pacman)
+            sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg
+            ;;
+        zypper)
+            sudo zypper install -y python3 python3-pip git curl ffmpeg
+            ;;
+        brew)
+            # Check if Homebrew is installed
+            if ! command -v brew &>/dev/null; then
+                log_error "Homebrew not found. Please install it first: https://brew.sh"
+                exit 1
+            fi
+            brew install python git curl ffmpeg
+            ;;
+    esac
+
+    log_success "System dependencies installed"
+}
+
+# -----------------------------------------------------------------------------
+# Installation Steps
+# -----------------------------------------------------------------------------
+
+create_user() {
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        return  # Skip user creation on macOS
+    fi
+
+    if id "$SERVICE_USER" &>/dev/null; then
+        log_info "User '$SERVICE_USER' already exists"
+        return
+    fi
+
+    log_info "Creating service user '$SERVICE_USER'..."
+    sudo useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SERVICE_USER"
+    log_success "Service user created"
+}
+
+download_bambuddy() {
+    log_info "Downloading BamBuddy..."
+
+    if [[ -d "$INSTALL_PATH/.git" ]]; then
+        log_info "Existing installation found, updating..."
+        # Add safe.directory to avoid "dubious ownership" error when running as root
+        git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true
+        cd "$INSTALL_PATH"
+        git fetch origin
+        git reset --hard origin/main
+        # Ensure correct ownership after update
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+    else
+        sudo mkdir -p "$INSTALL_PATH"
+        sudo chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+        git clone https://github.com/maziggy/bambuddy.git "$INSTALL_PATH"
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true
+    fi
+
+    log_success "BamBuddy downloaded to $INSTALL_PATH"
+}
+
+setup_virtualenv() {
+    log_info "Setting up Python virtual environment..."
+
+    cd "$INSTALL_PATH"
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        $PYTHON_CMD -m venv venv
+        source venv/bin/activate
+    else
+        sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv
+        source venv/bin/activate
+    fi
+
+    pip install --upgrade pip
+    pip install -r requirements.txt
+
+    log_success "Virtual environment configured"
+}
+
+check_node_version() {
+    # Returns 0 if Node.js 20+ is available, 1 otherwise
+    if ! command -v node &>/dev/null; then
+        return 1
+    fi
+
+    local version
+    version=$(node --version 2>/dev/null | sed 's/^v//')
+    local major
+    major=$(echo "$version" | cut -d'.' -f1)
+
+    if [[ "$major" -ge 20 ]]; then
+        log_success "Found Node.js v$version"
+        return 0
+    else
+        log_warn "Found Node.js v$version (need 20+)"
+        return 1
+    fi
+}
+
+install_nodejs() {
+    log_info "Installing Node.js 22..."
+    case "$PKG_MANAGER" in
+        apt)
+            # Remove old nodejs if present
+            sudo apt-get remove -y nodejs npm 2>/dev/null || true
+            curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+            sudo apt-get install -y nodejs
+            ;;
+        dnf|yum)
+            sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true
+            curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
+            sudo $PKG_MANAGER install -y nodejs
+            ;;
+        pacman)
+            sudo pacman -S --noconfirm nodejs npm
+            ;;
+        zypper)
+            sudo zypper install -y nodejs22
+            ;;
+        brew)
+            brew install node@22
+            brew link --overwrite node@22
+            ;;
+        *)
+            log_error "Please install Node.js 20+ manually: https://nodejs.org/"
+            exit 1
+            ;;
+    esac
+    # Refresh PATH
+    hash -r 2>/dev/null || true
+}
+
+build_frontend() {
+    log_info "Building frontend..."
+
+    cd "$INSTALL_PATH/frontend"
+
+    # Check for Node.js 20+
+    if ! check_node_version; then
+        install_nodejs
+        # Verify installation
+        if ! check_node_version; then
+            log_error "Failed to install Node.js 20+. Please install manually."
+            exit 1
+        fi
+    fi
+
+    npm ci
+    npm run build
+
+    log_success "Frontend built"
+}
+
+create_directories() {
+    log_info "Creating data directories..."
+
+    sudo mkdir -p "$DATA_DIR" "$LOG_DIR"
+
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR"
+    fi
+
+    log_success "Directories created"
+}
+
+create_env_file() {
+    log_info "Creating environment configuration..."
+
+    local env_file="$INSTALL_PATH/.env"
+
+    # Note: Only include settings recognized by the app's pydantic Settings class
+    # Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service
+    cat > /tmp/bambuddy.env << EOF
+# BamBuddy Configuration
+# Generated by install.sh on $(date)
+
+# Debug mode (true = verbose logging)
+DEBUG=$DEBUG_MODE
+
+# Log level (only used when DEBUG=false)
+# Options: DEBUG, INFO, WARNING, ERROR
+LOG_LEVEL=$LOG_LEVEL
+
+# Enable file logging
+LOG_TO_FILE=true
+EOF
+
+    sudo mv /tmp/bambuddy.env "$env_file"
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file"
+    fi
+    sudo chmod 600 "$env_file"
+
+    log_success "Environment file created at $env_file"
+}
+
+create_systemd_service() {
+    if [[ "$OS_TYPE" == "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
+        return
+    fi
+
+    log_info "Creating systemd service..."
+
+    cat > /tmp/bambuddy.service << EOF
+[Unit]
+Description=BamBuddy - Bambu Lab Print Management
+Documentation=https://github.com/maziggy/bambuddy
+After=network.target
+
+[Service]
+Type=simple
+User=$SERVICE_USER
+Group=$SERVICE_USER
+WorkingDirectory=$INSTALL_PATH
+
+# App settings from .env file
+EnvironmentFile=$INSTALL_PATH/.env
+
+# Service settings (not in .env to avoid pydantic validation errors)
+Environment="DATA_DIR=$DATA_DIR"
+Environment="LOG_DIR=$LOG_DIR"
+Environment="TZ=$TIMEZONE"
+
+ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT
+Restart=on-failure
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+
+# Security hardening
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+    sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service
+    sudo systemctl daemon-reload
+
+    log_success "Systemd service created"
+
+    if prompt_yes_no "Enable BamBuddy to start on boot?" "y"; then
+        sudo systemctl enable bambuddy
+        log_success "Service enabled"
+    fi
+
+    if prompt_yes_no "Start BamBuddy now?" "y"; then
+        sudo systemctl start bambuddy
+        sleep 2
+        if sudo systemctl is-active --quiet bambuddy; then
+            log_success "BamBuddy is running"
+        else
+            log_warn "Service may have failed to start. Check: sudo journalctl -u bambuddy -f"
+        fi
+    fi
+}
+
+create_launchd_service() {
+    if [[ "$OS_TYPE" != "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then
+        return
+    fi
+
+    log_info "Creating launchd service..."
+
+    local plist_path="$HOME/Library/LaunchAgents/com.bambuddy.app.plist"
+
+    cat > "$plist_path" << EOF
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+    <key>Label</key>
+    <string>com.bambuddy.app</string>
+    <key>ProgramArguments</key>
+    <array>
+        <string>$INSTALL_PATH/venv/bin/uvicorn</string>
+        <string>backend.app.main:app</string>
+        <string>--host</string>
+        <string>$BIND_ADDRESS</string>
+        <string>--port</string>
+        <string>$PORT</string>
+    </array>
+    <key>WorkingDirectory</key>
+    <string>$INSTALL_PATH</string>
+    <key>EnvironmentVariables</key>
+    <dict>
+        <key>DEBUG</key>
+        <string>$DEBUG_MODE</string>
+        <key>LOG_LEVEL</key>
+        <string>$LOG_LEVEL</string>
+        <key>DATA_DIR</key>
+        <string>$DATA_DIR</string>
+        <key>LOG_DIR</key>
+        <string>$LOG_DIR</string>
+        <key>TZ</key>
+        <string>$TIMEZONE</string>
+    </dict>
+    <key>RunAtLoad</key>
+    <true/>
+    <key>KeepAlive</key>
+    <true/>
+    <key>StandardOutPath</key>
+    <string>$LOG_DIR/bambuddy.log</string>
+    <key>StandardErrorPath</key>
+    <string>$LOG_DIR/bambuddy.error.log</string>
+</dict>
+</plist>
+EOF
+
+    log_success "Launchd plist created at $plist_path"
+
+    if prompt_yes_no "Load BamBuddy service now?" "y"; then
+        launchctl load "$plist_path"
+        sleep 2
+        if launchctl list | grep -q "com.bambuddy.app"; then
+            log_success "BamBuddy is running"
+        else
+            log_warn "Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log"
+        fi
+    fi
+}
+
+# -----------------------------------------------------------------------------
+# Main Installation Flow
+# -----------------------------------------------------------------------------
+
+parse_args() {
+    while [[ $# -gt 0 ]]; do
+        case "$1" in
+            --path)
+                INSTALL_PATH="$2"
+                shift 2
+                ;;
+            --port)
+                PORT="$2"
+                shift 2
+                ;;
+            --bind)
+                BIND_ADDRESS="$2"
+                shift 2
+                ;;
+            --tz)
+                TIMEZONE="$2"
+                shift 2
+                ;;
+            --data-dir)
+                DATA_DIR="$2"
+                shift 2
+                ;;
+            --log-dir)
+                LOG_DIR="$2"
+                shift 2
+                ;;
+            --debug)
+                DEBUG_MODE="true"
+                shift
+                ;;
+            --log-level)
+                LOG_LEVEL="$2"
+                shift 2
+                ;;
+            --no-service)
+                SKIP_SERVICE="true"
+                shift
+                ;;
+            --set-system-tz)
+                SET_SYSTEM_TZ="true"
+                shift
+                ;;
+            --yes|-y)
+                NON_INTERACTIVE="true"
+                shift
+                ;;
+            --help|-h)
+                show_help
+                ;;
+            *)
+                log_error "Unknown option: $1"
+                show_help
+                ;;
+        esac
+    done
+}
+
+gather_config() {
+    echo ""
+    echo -e "${BOLD}Installation Configuration${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    # Installation path
+    [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH
+
+    # Port
+    [[ -z "$PORT" ]] && prompt "Port to listen on" "$DEFAULT_PORT" PORT
+
+    # Bind address
+    if [[ -z "$BIND_ADDRESS" ]]; then
+        echo ""
+        echo "Network access:"
+        echo "  0.0.0.0   - Accessible from other devices on your network (recommended)"
+        echo "  127.0.0.1 - Only accessible from this machine"
+        prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS
+    fi
+
+    # Timezone
+    detect_timezone
+    prompt "Timezone" "$TIMEZONE" TIMEZONE
+
+    # Offer to set system timezone if different from current (skip if already set via --set-system-tz)
+    if [[ -z "$SET_SYSTEM_TZ" ]]; then
+        local current_tz
+        current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true
+        if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "$current_tz" ]]; then
+            # Default to "n" so unattended installs don't change system TZ unless --set-system-tz is used
+            if prompt_yes_no "Set system timezone to $TIMEZONE?" "n"; then
+                SET_SYSTEM_TZ="true"
+            else
+                SET_SYSTEM_TZ="false"
+            fi
+        else
+            SET_SYSTEM_TZ="false"
+        fi
+    fi
+
+    # Data directory
+    [[ -z "$DATA_DIR" ]] && DATA_DIR="$INSTALL_PATH/data"
+    prompt "Data directory" "$DATA_DIR" DATA_DIR
+
+    # Log directory
+    [[ -z "$LOG_DIR" ]] && LOG_DIR="$INSTALL_PATH/logs"
+    prompt "Log directory" "$LOG_DIR" LOG_DIR
+
+    # Debug mode
+    if [[ -z "$DEBUG_MODE" ]]; then
+        if prompt_yes_no "Enable debug mode?" "n"; then
+            DEBUG_MODE="true"
+        else
+            DEBUG_MODE="false"
+        fi
+    fi
+
+    # Log level
+    if [[ -z "$LOG_LEVEL" ]]; then
+        echo ""
+        echo "Log levels: DEBUG, INFO, WARNING, ERROR"
+        prompt "Log level" "$DEFAULT_LOG_LEVEL" LOG_LEVEL
+    fi
+
+    # Confirm
+    echo ""
+    echo -e "${BOLD}Installation Summary${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo -e "  Install path:  ${GREEN}$INSTALL_PATH${NC}"
+    echo -e "  Port:          ${GREEN}$PORT${NC}"
+    echo -e "  Bind address:  ${GREEN}$BIND_ADDRESS${NC}"
+    echo -e "  Timezone:      ${GREEN}$TIMEZONE${NC}"
+    echo -e "  Data dir:      ${GREEN}$DATA_DIR${NC}"
+    echo -e "  Log dir:       ${GREEN}$LOG_DIR${NC}"
+    echo -e "  Debug mode:    ${GREEN}$DEBUG_MODE${NC}"
+    echo -e "  Log level:     ${GREEN}$LOG_LEVEL${NC}"
+    echo ""
+
+    if ! prompt_yes_no "Proceed with installation?" "y"; then
+        echo "Installation cancelled."
+        exit 0
+    fi
+}
+
+main() {
+    parse_args "$@"
+    print_banner
+
+    # Check if running via pipe (curl | bash) - interactive mode won't work
+    if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then
+        log_error "Interactive mode requires a terminal."
+        log_info "When using 'curl | bash', you must use non-interactive mode:"
+        echo ""
+        echo "    curl -fsSL URL | bash -s -- --yes"
+        echo ""
+        log_info "Or download and run directly:"
+        echo ""
+        echo "    curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh"
+        echo ""
+        exit 1
+    fi
+
+    # Check for root (we need sudo for some operations)
+    if [[ "$EUID" -eq 0 ]] && [[ "$OS_TYPE" != "macos" ]]; then
+        log_warn "Running as root. Consider using a regular user with sudo privileges."
+    fi
+
+    # Detect system
+    log_info "Detecting system..."
+    detect_os
+    log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)"
+
+    # Check/install Python
+    if ! detect_python; then
+        log_info "Python 3.10+ not found, will install..."
+    fi
+
+    # Gather configuration
+    gather_config
+
+    # Install steps
+    echo ""
+    echo -e "${BOLD}Starting Installation${NC}"
+    echo -e "${CYAN}─────────────────────────────────────────${NC}"
+    echo ""
+
+    install_dependencies
+    detect_python || { log_error "Failed to install Python"; exit 1; }
+
+    # Set system timezone if requested
+    if [[ "$SET_SYSTEM_TZ" == "true" ]]; then
+        log_info "Setting system timezone to $TIMEZONE..."
+        if [[ "$OS_TYPE" == "macos" ]]; then
+            sudo systemsetup -settimezone "$TIMEZONE" 2>/dev/null || true
+        else
+            sudo timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true
+        fi
+        log_success "System timezone set to $TIMEZONE"
+    fi
+
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        create_user
+    else
+        SERVICE_USER="$USER"
+    fi
+
+    download_bambuddy
+    setup_virtualenv
+    build_frontend
+    create_directories
+    create_env_file
+
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        create_launchd_service
+    else
+        create_systemd_service
+    fi
+
+    # Done!
+    echo ""
+    echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}║              Installation Complete!                          ║${NC}"
+    echo -e "${GREEN}║                                                              ║${NC}"
+    echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}"
+    echo ""
+    # Show appropriate URL based on bind address
+    if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then
+        local ip_addr
+        ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="<your-ip>"
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+        echo -e "                    ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)"
+    else
+        echo -e "  ${BOLD}Access BamBuddy:${NC}  ${CYAN}http://localhost:$PORT${NC}"
+    fi
+    echo ""
+    if [[ "$OS_TYPE" == "macos" ]]; then
+        echo -e "  ${BOLD}Manage service:${NC}"
+        echo -e "    Start:   launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist"
+        echo -e "    Stop:    launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist"
+        echo -e "    Logs:    tail -f $LOG_DIR/bambuddy.log"
+    else
+        echo -e "  ${BOLD}Manage service:${NC}"
+        echo -e "    Status:  sudo systemctl status bambuddy"
+        echo -e "    Start:   sudo systemctl start bambuddy"
+        echo -e "    Stop:    sudo systemctl stop bambuddy"
+        echo -e "    Logs:    sudo journalctl -u bambuddy -f"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Update BamBuddy:${NC}"
+    echo -e "    cd $INSTALL_PATH && git pull && source venv/bin/activate"
+    echo -e "    pip install -r requirements.txt && cd frontend && npm ci && npm run build"
+    if [[ "$OS_TYPE" != "macos" ]]; then
+        echo -e "    sudo systemctl restart bambuddy"
+    fi
+    echo ""
+    echo -e "  ${BOLD}Documentation:${NC}  ${CYAN}https://wiki.bambuddy.cool${NC}"
+    echo ""
+}
+
+main "$@"

+ 5 - 0
requirements.txt

@@ -34,6 +34,11 @@ aiofiles>=23.0.0
 # QR Code generation
 # QR Code generation
 qrcode[pil]>=7.4.0
 qrcode[pil]>=7.4.0
 
 
+# STL Thumbnail Generation
+trimesh>=4.0.0
+matplotlib>=3.8.0
+fast-simplification>=0.1.0
+
 # System monitoring
 # System monitoring
 psutil>=6.0.0
 psutil>=6.0.0
 
 

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-B3Buyqqk.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-BJQC4Kk-.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-BTpjfCpx.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-Bwf4poPr.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-C1mIxrzF.css


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-DlQJz8pN.js


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
static/assets/index-wxwVsx5u.js


Някои файлове не бяха показани, защото твърде много файлове са промени