Просмотр исходного кода

Merge pull request #142 from maziggy/0.1.6b11

v0.1.6b11

  **New Features**

  Optional Authentication & User Management (PR #117)
  - Enable password protection for your BamBuddy instance
  - Multi-user support with admin and regular user roles
  - User management page in Settings for admins
  - Login page with session handling
  - Initial setup wizard for first-time configuration
  - API key support for programmatic access

  Home Assistant Energy Sensor Support (#119)
  - Configure dedicated power (W), energy today (kWh), and total energy (kWh) sensors for HA smart plugs
  - Enables energy tracking for plugs that expose data as separate sensors (Tapo P110M, IKEA Zigbee2mqtt, etc.)
  - Searchable dropdowns for selecting HA entities and energy sensors
  - Print energy tracking now works for HA plugs, not just Tasmota

  ZIP File Support in File Manager (#121)
  - Upload ZIP files to automatically extract contents
  - Option to preserve folder structure or flatten files

  Finish Photo URL in Notifications (#126)
  - New {finish_photo_url} template variable for print_complete, print_failed, print_stopped events
  - Photo capture completes before notification is sent
  - New External URL setting in Settings → Network for constructing URLs for external services

  Camera Viewer Improvements (#132)
  - Proper zoom and fullscreen support for both embedded and window viewing modes

  **Bug Fixes**

  - Print Time Stats (#137): Quick Stats now uses actual elapsed time instead of slicer estimates; cancelled prints only count time actually printed
  - Skip Objects Modal Overflow (#134): Modal now scrollable when printing many objects
  - Mattermost/Slack Webhooks (#133): Added "Slack / Mattermost" payload format option for compatibility
  - Camera Protocol Detection (#127): Fixed P2S and other models reporting internal codes (e.g., "N7") not being recognized for RTSP
  - Filament Cost Setting: Default filament cost setting now properly used instead of hardcoded value
  - Spoolman Tag Field (#123): Auto-creates required 'tag' extra field on Spoolman connect
  - Telegram Markdown: Fixed parsing errors when messages contain URLs
  - Re-auth Setup: Fixed authentication re-setup flow

  **Improvements**

  - Rearranged Network settings cards (Home Assistant moved to right column above MQTT)
  - Delete operations in File Manager now show loading indicator
  - P2S added to printer model dropdown
  - GitHub issue template updated with Support Package instructions
  - Added timeouts to GitHub CI runner

  **Internal**

  - Added internal model code mapping for camera protocol detection (BL-P001, C13, O1D, N7, etc.)
  - New tests for ZIP extraction, HA energy sensors, and authentication
  - Updated CI configuration
MartinNYHC 7 месяцев назад
Родитель
Сommit
1cbde60e90
66 измененных файлов с 4742 добавлено и 281 удалено
  1. 16 3
      .github/workflows/ci.yml
  2. 3 0
      .gitignore
  3. 49 0
      CHANGELOG.md
  4. 9 1
      README.md
  5. 28 5
      backend/app/api/routes/archives.py
  6. 253 0
      backend/app/api/routes/auth.py
  7. 223 0
      backend/app/api/routes/library.py
  8. 4 0
      backend/app/api/routes/printers.py
  9. 70 0
      backend/app/api/routes/settings.py
  10. 28 2
      backend/app/api/routes/smart_plugs.py
  11. 3 0
      backend/app/api/routes/spoolman.py
  12. 205 0
      backend/app/api/routes/users.py
  13. 301 58
      backend/app/core/auth.py
  14. 1 1
      backend/app/core/config.py
  15. 34 0
      backend/app/core/database.py
  16. 66 11
      backend/app/main.py
  17. 2 0
      backend/app/models/__init__.py
  18. 4 0
      backend/app/models/smart_plug.py
  19. 20 0
      backend/app/models/user.py
  20. 47 0
      backend/app/schemas/auth.py
  21. 27 0
      backend/app/schemas/library.py
  22. 14 3
      backend/app/schemas/notification_template.py
  23. 6 0
      backend/app/schemas/settings.py
  24. 17 0
      backend/app/schemas/smart_plug.py
  25. 5 2
      backend/app/services/archive.py
  26. 14 1
      backend/app/services/camera.py
  27. 120 21
      backend/app/services/homeassistant.py
  28. 30 11
      backend/app/services/notification_service.py
  29. 10 0
      backend/app/services/printer_manager.py
  30. 36 0
      backend/app/services/spoolman.py
  31. 3 1
      backend/tests/conftest.py
  32. 362 0
      backend/tests/integration/test_auth_api.py
  33. 103 0
      backend/tests/integration/test_library_api.py
  34. 52 0
      backend/tests/integration/test_smart_plugs_api.py
  35. 1 0
      backend/tests/integration/test_spoolman_api.py
  36. 71 0
      backend/tests/unit/services/test_notification_service.py
  37. 28 0
      backend/tests/unit/services/test_printer_manager.py
  38. 3 3
      docker-publish.sh
  39. 92 23
      frontend/src/App.tsx
  40. 42 0
      frontend/src/__tests__/components/ConfirmModal.test.tsx
  41. 3 0
      frontend/src/__tests__/components/Layout.test.tsx
  42. 157 0
      frontend/src/__tests__/pages/LoginPage.test.tsx
  43. 3 0
      frontend/src/__tests__/pages/SettingsPage.test.tsx
  44. 4 1
      frontend/src/__tests__/utils.tsx
  45. 187 6
      frontend/src/api/client.ts
  46. 10 4
      frontend/src/components/AddNotificationModal.tsx
  47. 413 38
      frontend/src/components/AddSmartPlugModal.tsx
  48. 19 7
      frontend/src/components/ConfirmModal.tsx
  49. 149 17
      frontend/src/components/EmbeddedCameraViewer.tsx
  50. 33 1
      frontend/src/components/Layout.tsx
  51. 135 0
      frontend/src/contexts/AuthContext.tsx
  52. 103 3
      frontend/src/pages/CameraPage.tsx
  53. 86 31
      frontend/src/pages/FileManagerPage.tsx
  54. 103 0
      frontend/src/pages/LoginPage.tsx
  55. 11 6
      frontend/src/pages/PrintersPage.tsx
  56. 316 15
      frontend/src/pages/SettingsPage.tsx
  57. 188 0
      frontend/src/pages/SetupPage.tsx
  58. 409 0
      frontend/src/pages/UsersPage.tsx
  59. 1 0
      frontend/tailwind.config.js
  60. 4 0
      requirements.txt
  61. 0 0
      static/assets/index-BmODu1qm.css
  62. 0 0
      static/assets/index-CPB3KMs0.js
  63. 0 0
      static/assets/index-CVDQtTMh.css
  64. 0 0
      static/assets/index-DFo1_Rau.js
  65. 2 2
      static/index.html
  66. 4 4
      test_docker.sh

+ 16 - 3
.github/workflows/ci.yml

@@ -4,7 +4,10 @@ on:
   push:
     branches: [main]
   pull_request:
-    branches: [main]
+    # Run on all PRs, but skip for repo owner (runs local tests)
+
+# Skip CI for repo owner's PRs (they run tests locally)
+# This check is applied to all jobs below
 
 env:
   PYTHON_VERSION: '3.11'
@@ -23,6 +26,7 @@ jobs:
   backend-lint:
     name: Backend Lint
     runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.actor != github.repository_owner
     steps:
       - uses: actions/checkout@v4
 
@@ -43,6 +47,7 @@ jobs:
   backend-tests:
     name: Backend Tests
     runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.actor != github.repository_owner
     needs: backend-lint
     steps:
       - uses: actions/checkout@v4
@@ -64,12 +69,13 @@ jobs:
         run: |
           python -m pip install --upgrade pip
           pip install -r requirements.txt
-          pip install pytest pytest-asyncio pytest-cov
+          pip install pytest pytest-asyncio pytest-cov pytest-timeout
 
       - name: Run tests
+        timeout-minutes: 10
         run: |
           cd backend
-          python -m pytest tests/ -v --tb=short
+          python -m pytest tests/ -v --tb=short --timeout=60 --timeout-method=thread
 
   # ============================================================================
   # Frontend Checks
@@ -78,6 +84,7 @@ jobs:
   frontend-lint:
     name: Frontend Lint
     runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.actor != github.repository_owner
     steps:
       - uses: actions/checkout@v4
 
@@ -99,6 +106,7 @@ jobs:
   frontend-typecheck:
     name: Frontend Type Check
     runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.actor != github.repository_owner
     steps:
       - uses: actions/checkout@v4
 
@@ -120,6 +128,7 @@ jobs:
   frontend-tests:
     name: Frontend Tests
     runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.actor != github.repository_owner
     needs: [frontend-lint, frontend-typecheck]
     steps:
       - uses: actions/checkout@v4
@@ -136,12 +145,14 @@ jobs:
         run: npm ci
 
       - name: Run tests
+        timeout-minutes: 10
         working-directory: frontend
         run: npm run test:run
 
   frontend-build:
     name: Frontend Build
     runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.actor != github.repository_owner
     needs: [frontend-tests]
     steps:
       - uses: actions/checkout@v4
@@ -168,6 +179,8 @@ jobs:
   docker-test:
     name: Docker Build
     runs-on: ubuntu-latest
+    if: github.event_name == 'push' || github.actor != github.repository_owner
+    timeout-minutes: 20
     needs: [backend-tests, frontend-build]
     steps:
       - uses: actions/checkout@v4

+ 3 - 0
.gitignore

@@ -53,3 +53,6 @@ logs/
 *.log*
 bambutrack.log.*
 firmware/
+
+# Node modules
+node_modules/

+ 49 - 0
CHANGELOG.md

@@ -2,6 +2,47 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
+## [0.1.6b11] - 2026-01-22
+
+### New Features
+- **Camera Zoom & Fullscreen** - Enhanced camera viewer controls:
+  - Fullscreen mode for embedded camera viewer (new button in header)
+  - Zoom controls (100%-400%) for both embedded and window modes
+  - Pan support when zoomed in (click and drag)
+  - Mouse wheel zoom support
+  - Zoom resets on mode switch, refresh, or fullscreen toggle
+- **Searchable HA Entity Selection** - Improved Home Assistant smart plug configuration:
+  - Entity dropdown replaced with searchable combobox
+  - Type to search across all HA entities (not just switch/light/input_boolean)
+  - Energy sensor dropdowns (Power, Energy Today, Total) are now searchable
+  - Find sensors with non-standard naming that don't match the switch entity name
+- **Home Assistant Energy Sensor Support** - HA smart plugs can now use separate sensor entities for energy monitoring:
+  - Configure dedicated power sensor (W), today's energy (kWh), and total energy (kWh) sensors
+  - Supports plugs where energy data is exposed as separate sensor entities (common with Tapo, IKEA Zigbee2mqtt, etc.)
+  - Energy sensors are selectable from all available HA sensors with power/energy units
+  - Falls back to switch entity attributes if no sensors configured
+  - Print energy tracking now works correctly for HA plugs (not just Tasmota)
+  - New API endpoint: `GET /api/v1/smart-plugs/ha/sensors` to list available energy sensors
+- **Finish Photo in Notifications** - Camera snapshot URL available in notification templates (Issue #126):
+  - New `{finish_photo_url}` template variable for print_complete, print_failed, print_stopped events
+  - Photo is captured before notification is sent (ensures image is available)
+  - New "External URL" setting in Settings → Network (auto-detects from browser)
+  - Full URL constructed for external notification services (Telegram, Email, Discord, etc.)
+- **ZIP File Support in File Manager** - Upload and extract ZIP files directly in the library (Issue #121):
+  - Drop or select ZIP files to automatically extract contents
+  - Option to preserve folder structure from ZIP or extract flat
+  - Extracts thumbnails and metadata from 3MF/gcode files inside ZIP
+  - Progress indicator shows number of files extracted
+
+### Fixed
+- **Print time stats using slicer estimates** - Quick Stats "Print Time" now uses actual elapsed time (`completed_at - started_at`) instead of slicer estimates; cancelled prints only count time actually printed (Issue #137)
+- **Skip objects modal overflow** - Modal now has max height (85vh) with scrollable object list when printing many items on the bed (Issue #134)
+- **Filament cost using wrong default** - Statistics now correctly uses the "Default filament cost (per kg)" setting instead of hardcoded €25 value (Issue #120)
+- **Spoolman tag field not auto-created** - The required "tag" extra field is now automatically created in Spoolman on first connect, fixing sync failures for fresh Spoolman installs (Issue #123)
+- **P2S/X1E/H2 completion photo not captured** - Internal model codes (N7, C13, O1D, etc.) from MQTT/SSDP are now recognized for RTSP camera support (Issue #127)
+- **Mattermost/Slack webhook 400 error** - Added "Slack / Mattermost" payload format option that sends `{"text": "..."}` instead of custom fields (Issue #133)
+- **Subnet scan serial number** - Fixed A1 Mini subnet discovery showing "unknown-*" placeholder; serial field is now cleared so users know to enter it manually (Issue #140)
+
 ## [0.1.6b10] - 2026-01-21
 
 ### New Features
@@ -71,6 +112,14 @@ All notable changes to Bambuddy will be documented in this file.
   - Three-dot menu button always visible on mobile (hover-only on desktop)
   - Selection checkbox always visible on mobile devices
   - Better PWA experience for file management
+- **Optional Authentication** - Secure your Bambuddy instance with user authentication:
+  - Enable/disable authentication via Setup page or Settings → Users
+  - Role-based access control: Admin and User roles
+  - Admins have full access; Users can manage prints but not settings
+  - JWT-based authentication with 7-day token expiration
+  - User management page for creating, editing, and deleting users
+  - Backward compatible: existing installations work without authentication
+  - Settings page restricted to admin users when auth is enabled
 
 ### Changed
 - **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)

+ 9 - 1
README.md

@@ -76,7 +76,8 @@
 - Scheduled prints (date/time)
 - Queue Only mode (stage without auto-start)
 - Smart plug integration (Tasmota, Home Assistant)
-- Energy consumption tracking
+- Energy consumption tracking (per-print kWh and cost)
+- HA energy sensor support (for plugs with separate power/energy sensors)
 - Auto power-on before print
 - Auto power-off after cooldown
 
@@ -106,6 +107,7 @@
 - Custom webhooks
 - Quiet hours & daily digest
 - Customizable message templates
+- Print finish photo URL in notifications
 
 ### 🔧 Integrations
 - [Spoolman](https://github.com/Donkie/Spoolman) filament sync
@@ -134,6 +136,12 @@
 - Live application log viewer with filtering
 - Support bundle generator (privacy-filtered)
 
+### 🔒 Optional Authentication
+- Enable/disable authentication any time
+- Role-based access (Admin/User)
+- JWT tokens with secure password hashing
+- User management (create, edit, delete)
+
 </td>
 </tr>
 </table>

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

@@ -425,9 +425,23 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     failed_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed"))
     failed_prints = failed_result.scalar() or 0
 
-    # Totals
-    time_result = await db.execute(select(func.sum(PrintArchive.print_time_seconds)))
-    total_time = (time_result.scalar() or 0) / 3600  # Convert to hours
+    # Totals - use actual print time from timestamps (not slicer estimates)
+    # For archives with both started_at and completed_at, calculate actual duration
+    # Fall back to print_time_seconds only for archives without timestamps
+    archives_for_time = await db.execute(
+        select(PrintArchive.started_at, PrintArchive.completed_at, PrintArchive.print_time_seconds)
+    )
+    total_seconds = 0
+    for started_at, completed_at, print_time_seconds in archives_for_time.all():
+        if started_at and completed_at:
+            # Use actual elapsed time
+            actual_seconds = (completed_at - started_at).total_seconds()
+            if actual_seconds > 0:
+                total_seconds += actual_seconds
+        elif print_time_seconds:
+            # Fallback to estimate only if no timestamps
+            total_seconds += print_time_seconds
+    total_time = total_seconds / 3600  # Convert to hours
 
     filament_result = await db.execute(select(func.sum(PrintArchive.filament_used_grams)))
     total_filament = filament_result.scalar() or 0
@@ -627,6 +641,7 @@ async def toggle_favorite(
 @router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
 async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     """Rescan the 3MF file and update metadata."""
+    from backend.app.api.routes.settings import get_setting
     from backend.app.services.archive import ThreeMFParser
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -672,7 +687,10 @@ async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
         if filament:
             archive.cost = round((archive.filament_used_grams / 1000) * filament.cost_per_kg, 2)
         else:
-            archive.cost = round((archive.filament_used_grams / 1000) * 25.0, 2)
+            # Use default filament cost from settings
+            default_cost_setting = await get_setting(db, "default_filament_cost")
+            default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
+            archive.cost = round((archive.filament_used_grams / 1000) * default_cost_per_kg, 2)
 
     await db.commit()
     await db.refresh(archive)
@@ -682,13 +700,18 @@ async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
 @router.post("/recalculate-costs")
 async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
     """Recalculate costs for all archives based on filament usage and prices."""
+    from backend.app.api.routes.settings import get_setting
+
     result = await db.execute(select(PrintArchive))
     archives = list(result.scalars().all())
 
     # Load all filaments for lookup
     filament_result = await db.execute(select(Filament))
     filaments = {f.type: f.cost_per_kg for f in filament_result.scalars().all()}
-    default_cost_per_kg = 25.0
+
+    # Get default filament cost from settings
+    default_cost_setting = await get_setting(db, "default_filament_cost")
+    default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
 
     updated = 0
     for archive in archives:

+ 253 - 0
backend/app/api/routes/auth.py

@@ -0,0 +1,253 @@
+from datetime import timedelta
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import (
+    ACCESS_TOKEN_EXPIRE_MINUTES,
+    authenticate_user,
+    create_access_token,
+    get_current_active_user,
+    get_password_hash,
+    get_user_by_username,
+)
+from backend.app.core.database import get_db
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.schemas.auth import LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
+
+router = APIRouter(prefix="/auth", tags=["authentication"])
+
+
+async def is_auth_enabled(db: AsyncSession) -> bool:
+    """Check if authentication is enabled."""
+    result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
+    setting = result.scalar_one_or_none()
+    if setting is None:
+        return False
+    return setting.value.lower() == "true"
+
+
+async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
+    """Set authentication enabled status."""
+    from sqlalchemy import func
+    from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+
+    stmt = sqlite_insert(Settings).values(key="auth_enabled", value="true" if enabled else "false")
+    stmt = stmt.on_conflict_do_update(
+        index_elements=["key"], set_={"value": "true" if enabled else "false", "updated_at": func.now()}
+    )
+    await db.execute(stmt)
+    # Note: Don't commit here - let get_db handle it or commit explicitly in the route
+
+
+async def is_setup_completed(db: AsyncSession) -> bool:
+    """Check if setup has been completed."""
+    result = await db.execute(select(Settings).where(Settings.key == "setup_completed"))
+    setting = result.scalar_one_or_none()
+    return setting and setting.value.lower() == "true"
+
+
+async def set_setup_completed(db: AsyncSession, completed: bool) -> None:
+    """Set setup completed status."""
+    from sqlalchemy import func
+    from sqlalchemy.dialects.sqlite import insert as sqlite_insert
+
+    stmt = sqlite_insert(Settings).values(key="setup_completed", value="true" if completed else "false")
+    stmt = stmt.on_conflict_do_update(
+        index_elements=["key"], set_={"value": "true" if completed else "false", "updated_at": func.now()}
+    )
+    await db.execute(stmt)
+    # Note: Don't commit here - let get_db handle it or commit explicitly in the route
+
+
+@router.post("/setup", response_model=SetupResponse)
+async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
+    """First-time setup: enable/disable authentication and create admin user."""
+    import logging
+
+    logger = logging.getLogger(__name__)
+
+    try:
+        # Check if auth is already configured (prevent re-setup)
+        result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
+        _existing_setting = result.scalar_one_or_none()
+
+        # Check if users exist
+        user_count_result = await db.execute(select(User))
+        _user_count = len(user_count_result.scalars().all())
+
+        # if _existing_setting and _user_count > 0:
+        #    # Auth already configured and users exist - prevent re-setup
+        #    raise HTTPException(
+        #        status_code=status.HTTP_400_BAD_REQUEST,
+        #        detail="Authentication is already configured. Use user management to modify users.",
+        #    )
+
+        # If auth_enabled is true but no users exist, allow re-setup (recovery scenario)
+
+        admin_created = False
+
+        if request.auth_enabled:
+            # Check if admin users already exist
+            admin_users_result = await db.execute(select(User).where(User.role == "admin"))
+            existing_admin_users = list(admin_users_result.scalars().all())
+            has_admin_users = len(existing_admin_users) > 0
+
+            if has_admin_users:
+                # Admin users already exist, just enable auth (don't create new admin)
+                logger.info(
+                    f"Admin users already exist ({len(existing_admin_users)} found), enabling authentication without creating new admin"
+                )
+                admin_created = False
+            else:
+                # No admin users exist, require admin credentials to create first admin
+                if not request.admin_username or not request.admin_password:
+                    raise HTTPException(
+                        status_code=status.HTTP_400_BAD_REQUEST,
+                        detail="Admin username and password are required when enabling authentication (no admin users exist)",
+                    )
+
+                # Check if username already exists (shouldn't happen if no admin users exist, but check anyway)
+                existing_user = await get_user_by_username(db, request.admin_username)
+                if existing_user:
+                    raise HTTPException(
+                        status_code=status.HTTP_400_BAD_REQUEST,
+                        detail="User with this username already exists",
+                    )
+
+                # Create admin user FIRST (before enabling auth)
+                try:
+                    logger.info(f"Creating admin user: {request.admin_username}")
+                    admin_user = User(
+                        username=request.admin_username,
+                        password_hash=get_password_hash(request.admin_password),
+                        role="admin",
+                        is_active=True,
+                    )
+                    db.add(admin_user)
+                    logger.info(f"Admin user added to session: {request.admin_username}")
+                    admin_created = True
+                except Exception as e:
+                    await db.rollback()
+                    logger.error(f"Failed to create admin user: {e}", exc_info=True)
+                    raise HTTPException(
+                        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+                        detail=f"Failed to create admin user: {str(e)}",
+                    )
+
+        # Set auth enabled and mark setup as completed
+        await set_auth_enabled(db, request.auth_enabled)
+        await set_setup_completed(db, True)
+        await db.commit()
+
+        if admin_created:
+            await db.refresh(admin_user)
+            logger.info(f"Admin user created successfully: {admin_user.id}")
+
+        logger.info(f"Setup completed: auth_enabled={request.auth_enabled}, admin_created={admin_created}")
+        return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
+    except HTTPException:
+        raise
+    except Exception as e:
+        logger.error(f"Setup error: {e}", exc_info=True)
+        await db.rollback()
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=f"Setup failed: {str(e)}",
+        )
+
+
+@router.get("/status")
+async def get_auth_status(db: AsyncSession = Depends(get_db)):
+    """Get authentication status (public endpoint)."""
+    auth_enabled = await is_auth_enabled(db)
+    setup_completed = await is_setup_completed(db)
+    # Only require setup if it hasn't been completed yet
+    requires_setup = not setup_completed
+    return {"auth_enabled": auth_enabled, "requires_setup": requires_setup}
+
+
+@router.post("/disable", response_model=dict)
+async def disable_auth(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+):
+    """Disable authentication (admin only)."""
+    import logging
+
+    logger = logging.getLogger(__name__)
+
+    # Only admins can disable authentication
+    if current_user.role != "admin":
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Only admins can disable authentication",
+        )
+
+    try:
+        await set_auth_enabled(db, False)
+        await db.commit()
+        logger.info(f"Authentication disabled by admin user: {current_user.username}")
+        return {"message": "Authentication disabled successfully", "auth_enabled": False}
+    except Exception as e:
+        await db.rollback()
+        logger.error(f"Failed to disable authentication: {e}", exc_info=True)
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=f"Failed to disable authentication: {str(e)}",
+        )
+
+
+@router.post("/login", response_model=LoginResponse)
+async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
+    """Login and get access token."""
+    # Check if auth is enabled
+    auth_enabled = await is_auth_enabled(db)
+    if not auth_enabled:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Authentication is not enabled",
+        )
+
+    user = await authenticate_user(db, request.username, request.password)
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Incorrect username or password",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
+    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+    access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
+
+    return LoginResponse(
+        access_token=access_token,
+        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(),
+        ),
+    )
+
+
+@router.get("/me", response_model=UserResponse)
+async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
+    """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(),
+    )
+
+
+@router.post("/logout")
+async def logout():
+    """Logout (client should discard token)."""
+    return {"message": "Logged out successfully"}

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

@@ -38,6 +38,9 @@ from backend.app.schemas.library import (
     FolderResponse,
     FolderTreeItem,
     FolderUpdate,
+    ZipExtractError,
+    ZipExtractResponse,
+    ZipExtractResult,
 )
 from backend.app.services.archive import ArchiveService, ThreeMFParser
 
@@ -740,6 +743,226 @@ async def upload_file(
         raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
 
 
+@router.post("/files/extract-zip", response_model=ZipExtractResponse)
+async def extract_zip_file(
+    file: UploadFile = File(...),
+    folder_id: int | None = None,
+    preserve_structure: bool = True,
+    db: AsyncSession = Depends(get_db),
+):
+    """Upload and extract a ZIP file to the library.
+
+    Args:
+        file: The ZIP file to extract
+        folder_id: Target folder ID (None = root)
+        preserve_structure: If True, recreate folder structure from ZIP; if False, extract all files flat
+    """
+    import tempfile
+    import zipfile
+
+    if not file.filename or not file.filename.lower().endswith(".zip"):
+        raise HTTPException(status_code=400, detail="Only ZIP files are supported")
+
+    # Verify target folder exists if specified
+    if folder_id is not None:
+        folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
+        if not folder_result.scalar_one_or_none():
+            raise HTTPException(status_code=404, detail="Target folder not found")
+
+    # Save ZIP to temp file
+    try:
+        with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp:
+            content = await file.read()
+            tmp.write(content)
+            tmp_path = tmp.name
+    except Exception as e:
+        raise HTTPException(status_code=500, detail=f"Failed to save ZIP file: {str(e)}")
+
+    extracted_files: list[ZipExtractResult] = []
+    errors: list[ZipExtractError] = []
+    folders_created = 0
+    folder_cache: dict[str, int] = {}  # path -> folder_id
+
+    try:
+        with zipfile.ZipFile(tmp_path, "r") as zf:
+            # Filter out directories and hidden/system files
+            file_list = [
+                name
+                for name in zf.namelist()
+                if not name.endswith("/")
+                and not name.startswith("__MACOSX")
+                and not os.path.basename(name).startswith(".")
+            ]
+
+            for zip_path in file_list:
+                try:
+                    # Determine target folder
+                    target_folder_id = folder_id
+
+                    if preserve_structure:
+                        # Get directory path from ZIP
+                        dir_path = os.path.dirname(zip_path)
+                        if dir_path:
+                            # Create folder structure
+                            parts = dir_path.split("/")
+                            current_parent = folder_id
+                            current_path = ""
+
+                            for part in parts:
+                                if not part:
+                                    continue
+                                current_path = f"{current_path}/{part}" if current_path else part
+
+                                if current_path in folder_cache:
+                                    current_parent = folder_cache[current_path]
+                                else:
+                                    # Check if folder exists
+                                    existing = await db.execute(
+                                        select(LibraryFolder).where(
+                                            LibraryFolder.name == part,
+                                            LibraryFolder.parent_id == current_parent
+                                            if current_parent
+                                            else LibraryFolder.parent_id.is_(None),
+                                        )
+                                    )
+                                    existing_folder = existing.scalar_one_or_none()
+
+                                    if existing_folder:
+                                        current_parent = existing_folder.id
+                                    else:
+                                        # Create folder
+                                        new_folder = LibraryFolder(name=part, parent_id=current_parent)
+                                        db.add(new_folder)
+                                        await db.flush()
+                                        current_parent = new_folder.id
+                                        folders_created += 1
+
+                                    folder_cache[current_path] = current_parent
+
+                            target_folder_id = current_parent
+
+                    # Extract file
+                    filename = os.path.basename(zip_path)
+                    ext = os.path.splitext(filename)[1].lower()
+                    file_type = ext[1:] if ext else "unknown"
+
+                    # Generate unique filename for storage
+                    unique_filename = f"{uuid.uuid4().hex}{ext}"
+                    file_path = get_library_files_dir() / unique_filename
+
+                    # Extract and save file
+                    file_content = zf.read(zip_path)
+                    with open(file_path, "wb") as f:
+                        f.write(file_content)
+
+                    # Calculate hash
+                    file_hash = calculate_file_hash(file_path)
+
+                    # Extract metadata and thumbnail for 3MF files
+                    metadata = {}
+                    thumbnail_path = None
+                    thumbnails_dir = get_library_thumbnails_dir()
+
+                    if ext == ".3mf":
+                        try:
+                            parser = ThreeMFParser(str(file_path))
+                            raw_metadata = parser.parse()
+
+                            thumbnail_data = raw_metadata.get("_thumbnail_data")
+                            thumbnail_ext = raw_metadata.get("_thumbnail_ext", ".png")
+
+                            if thumbnail_data:
+                                thumb_filename = f"{uuid.uuid4().hex}{thumbnail_ext}"
+                                thumb_path = thumbnails_dir / thumb_filename
+                                with open(thumb_path, "wb") as f:
+                                    f.write(thumbnail_data)
+                                thumbnail_path = str(thumb_path)
+
+                            def clean_metadata(obj):
+                                if isinstance(obj, dict):
+                                    return {
+                                        k: clean_metadata(v)
+                                        for k, v in obj.items()
+                                        if not isinstance(v, bytes) and k not in ("_thumbnail_data", "_thumbnail_ext")
+                                    }
+                                elif isinstance(obj, list):
+                                    return [clean_metadata(i) for i in obj if not isinstance(i, bytes)]
+                                elif isinstance(obj, bytes):
+                                    return None
+                                return obj
+
+                            metadata = clean_metadata(raw_metadata)
+                        except Exception as e:
+                            logger.warning(f"Failed to parse 3MF from ZIP: {e}")
+
+                    elif ext == ".gcode":
+                        try:
+                            thumbnail_data = extract_gcode_thumbnail(file_path)
+                            if thumbnail_data:
+                                thumb_filename = f"{uuid.uuid4().hex}.png"
+                                thumb_path = thumbnails_dir / thumb_filename
+                                with open(thumb_path, "wb") as f:
+                                    f.write(thumbnail_data)
+                                thumbnail_path = str(thumb_path)
+                        except Exception as e:
+                            logger.warning(f"Failed to extract gcode thumbnail from ZIP: {e}")
+
+                    elif ext.lower() in IMAGE_EXTENSIONS:
+                        thumbnail_path = create_image_thumbnail(file_path, thumbnails_dir)
+
+                    # Create database entry
+                    library_file = LibraryFile(
+                        folder_id=target_folder_id,
+                        filename=filename,
+                        file_path=str(file_path),
+                        file_type=file_type,
+                        file_size=len(file_content),
+                        file_hash=file_hash,
+                        thumbnail_path=thumbnail_path,
+                        file_metadata=metadata if metadata else None,
+                    )
+                    db.add(library_file)
+                    await db.flush()
+                    await db.refresh(library_file)
+
+                    extracted_files.append(
+                        ZipExtractResult(
+                            filename=filename,
+                            file_id=library_file.id,
+                            folder_id=target_folder_id,
+                        )
+                    )
+
+                    # Commit after each file to release database lock
+                    # This prevents long-running transactions from blocking other requests
+                    await db.commit()
+
+                except Exception as e:
+                    logger.error(f"Failed to extract {zip_path}: {e}")
+                    errors.append(ZipExtractError(filename=os.path.basename(zip_path), error=str(e)))
+                    # Rollback the failed file but continue with others
+                    await db.rollback()
+
+        return ZipExtractResponse(
+            extracted=len(extracted_files),
+            folders_created=folders_created,
+            files=extracted_files,
+            errors=errors,
+        )
+
+    except zipfile.BadZipFile:
+        raise HTTPException(status_code=400, detail="Invalid or corrupted ZIP file")
+    except Exception as e:
+        logger.error(f"ZIP extraction failed: {e}", exc_info=True)
+        raise HTTPException(status_code=500, detail=f"ZIP extraction failed: {str(e)}")
+    finally:
+        # Clean up temp file
+        try:
+            os.unlink(tmp_path)
+        except Exception:
+            pass
+
+
 # ============ Queue Operations ============
 # NOTE: These routes must be defined BEFORE /files/{file_id} to avoid path parameter conflicts
 

+ 4 - 0
backend/app/api/routes/printers.py

@@ -8,6 +8,7 @@ from fastapi.responses import Response
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequireAdminIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.models.printer import Printer
@@ -47,6 +48,7 @@ async def list_printers(db: AsyncSession = Depends(get_db)):
 async def create_printer(
     printer_data: PrinterCreate,
     db: AsyncSession = Depends(get_db),
+    _current_user=RequireAdminIfAuthEnabled(),
 ):
     """Add a new printer."""
     # Check if serial number already exists
@@ -81,6 +83,7 @@ async def update_printer(
     printer_id: int,
     printer_data: PrinterUpdate,
     db: AsyncSession = Depends(get_db),
+    _current_user=RequireAdminIfAuthEnabled(),
 ):
     """Update a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -109,6 +112,7 @@ async def delete_printer(
     printer_id: int,
     delete_archives: bool = True,
     db: AsyncSession = Depends(get_db),
+    _current_user=RequireAdminIfAuthEnabled(),
 ):
     """Delete a printer.
 

+ 70 - 0
backend/app/api/routes/settings.py

@@ -25,6 +25,7 @@ from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.spoolman import init_spoolman_client
@@ -242,6 +243,9 @@ async def export_backup(
     include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
     include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
     include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"),
+    include_users: bool = Query(
+        False, description="Include users (passwords not exported - users will need new passwords)"
+    ),
 ):
     """Export selected data as JSON backup."""
     backup: dict = {
@@ -338,6 +342,9 @@ async def export_backup(
                     "plug_type": plug.plug_type,
                     "ip_address": plug.ip_address,
                     "ha_entity_id": plug.ha_entity_id,
+                    "ha_power_entity": plug.ha_power_entity,
+                    "ha_energy_today_entity": plug.ha_energy_today_entity,
+                    "ha_energy_total_entity": plug.ha_energy_total_entity,
                     "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None,
                     "enabled": plug.enabled,
                     "auto_on": plug.auto_on,
@@ -776,6 +783,22 @@ async def export_backup(
             )
         backup["included"].append("api_keys")
 
+    # Users (note: passwords not exported for security - users will need new passwords on import)
+    if include_users:
+        result = await db.execute(select(User))
+        users = result.scalars().all()
+        backup["users"] = []
+        for user in users:
+            backup["users"].append(
+                {
+                    "username": user.username,
+                    "role": user.role,
+                    "is_active": user.is_active,
+                    # password_hash intentionally not exported for security
+                }
+            )
+        backup["included"].append("users")
+
     # If there are files to include (icons or archives), create ZIP file
     if backup_files:
         zip_buffer = io.BytesIO()
@@ -866,6 +889,7 @@ async def import_backup(
         "maintenance_types": 0,
         "projects": 0,
         "pending_uploads": 0,
+        "users": 0,
     }
     skipped = {
         "settings": 0,
@@ -879,6 +903,7 @@ async def import_backup(
         "archives": 0,
         "projects": 0,
         "pending_uploads": 0,
+        "users": 0,
     }
     skipped_details = {
         "notification_providers": [],
@@ -890,6 +915,7 @@ async def import_backup(
         "archives": [],
         "projects": [],
         "pending_uploads": [],
+        "users": [],
     }
 
     # Restore settings (always overwrites)
@@ -1099,6 +1125,9 @@ async def import_backup(
                     existing.name = plug_data["name"]
                     existing.plug_type = plug_type
                     existing.ha_entity_id = plug_data.get("ha_entity_id")
+                    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_total_entity = plug_data.get("ha_energy_total_entity")
                     existing.printer_id = printer_id
                     existing.enabled = plug_data.get("enabled", True)
                     existing.auto_on = plug_data.get("auto_on", True)
@@ -1125,6 +1154,9 @@ async def import_backup(
                     plug_type=plug_type,
                     ip_address=plug_data.get("ip_address"),
                     ha_entity_id=plug_data.get("ha_entity_id"),
+                    ha_power_entity=plug_data.get("ha_power_entity"),
+                    ha_energy_today_entity=plug_data.get("ha_energy_today_entity"),
+                    ha_energy_total_entity=plug_data.get("ha_energy_total_entity"),
                     printer_id=printer_id,
                     enabled=plug_data.get("enabled", True),
                     auto_on=plug_data.get("auto_on", True),
@@ -1772,6 +1804,40 @@ async def import_backup(
                     }
                 )
 
+    # 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
+    new_users: list[str] = []
+    if "users" in backup:
+        from backend.app.core.auth import get_password_hash
+
+        for user_data in backup["users"]:
+            result = await db.execute(select(User).where(User.username == user_data["username"]))
+            existing = result.scalar_one_or_none()
+            if existing:
+                if overwrite:
+                    existing.role = user_data.get("role", "user")
+                    existing.is_active = user_data.get("is_active", True)
+                    # Don't change password - keep existing
+                    restored["users"] += 1
+                else:
+                    skipped["users"] += 1
+                    skipped_details["users"].append(user_data["username"])
+            else:
+                # Create user with a temporary password that must be changed
+                # Generate a random temporary password
+                import secrets
+
+                temp_password = secrets.token_urlsafe(16)
+                user = User(
+                    username=user_data["username"],
+                    password_hash=get_password_hash(temp_password),
+                    role=user_data.get("role", "user"),
+                    is_active=user_data.get("is_active", True),
+                )
+                db.add(user)
+                restored["users"] += 1
+                new_users.append(f"{user_data['username']} (temp password: {temp_password})")
+
     await db.commit()
 
     # If printers were in the backup (restored, updated, or skipped), reconnect all active printers
@@ -1882,6 +1948,10 @@ async def import_backup(
     if new_api_keys:
         response["new_api_keys"] = new_api_keys
 
+    # Include newly created users with temp passwords (so admin can share them)
+    if new_users:
+        response["new_users"] = new_users
+
     return response
 
 

+ 28 - 2
backend/app/api/routes/smart_plugs.py

@@ -14,6 +14,7 @@ from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.schemas.smart_plug import (
     HAEntity,
+    HASensorEntity,
     HATestConnectionRequest,
     HATestConnectionResponse,
     SmartPlugControl,
@@ -210,9 +211,15 @@ async def test_ha_connection(request: HATestConnectionRequest):
 
 
 @router.get("/ha/entities", response_model=list[HAEntity])
-async def list_ha_entities(db: AsyncSession = Depends(get_db)):
+async def list_ha_entities(
+    db: AsyncSession = Depends(get_db),
+    search: str | None = None,
+):
     """List available Home Assistant entities.
 
+    By default, returns switch/light/input_boolean entities.
+    When search is provided, searches ALL entities by entity_id or friendly_name.
+
     Requires HA connection settings to be configured in Settings.
     """
     ha_url = await get_setting(db, "ha_url") or ""
@@ -223,10 +230,29 @@ async def list_ha_entities(db: AsyncSession = Depends(get_db)):
             400, "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant."
         )
 
-    entities = await homeassistant_service.list_entities(ha_url, ha_token)
+    entities = await homeassistant_service.list_entities(ha_url, ha_token, search)
     return [HAEntity(**e) for e in entities]
 
 
+@router.get("/ha/sensors", response_model=list[HASensorEntity])
+async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
+    """List available Home Assistant sensor entities for energy monitoring.
+
+    Returns sensors with power/energy units (W, kW, kWh, Wh).
+    Requires HA connection settings to be configured in Settings.
+    """
+    ha_url = await get_setting(db, "ha_url") or ""
+    ha_token = await get_setting(db, "ha_token") or ""
+
+    if not ha_url or not ha_token:
+        raise HTTPException(
+            400, "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant."
+        )
+
+    sensors = await homeassistant_service.list_sensor_entities(ha_url, ha_token)
+    return [HASensorEntity(**s) for s in sensors]
+
+
 @router.get("/{plug_id}", response_model=SmartPlugResponse)
 async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
     """Get a specific smart plug."""

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

@@ -109,6 +109,9 @@ async def connect_spoolman(db: AsyncSession = Depends(get_db)):
                 detail=f"Could not connect to Spoolman at {url}",
             )
 
+        # Ensure the 'tag' extra field exists for RFID/UUID storage
+        await client.ensure_tag_extra_field()
+
         return {"success": True, "message": f"Connected to Spoolman at {url}"}
     except Exception as e:
         logger.error(f"Failed to connect to Spoolman: {e}")

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

@@ -0,0 +1,205 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequireAdmin, get_password_hash
+from backend.app.core.database import get_db
+from backend.app.models.user import User
+from backend.app.schemas.auth import UserCreate, UserResponse, UserUpdate
+
+router = APIRouter(prefix="/users", tags=["users"])
+
+
+@router.get("", response_model=list[UserResponse])
+@router.get("/", response_model=list[UserResponse])
+async def list_users(
+    current_user: User = RequireAdmin(),
+    db: AsyncSession = Depends(get_db),
+):
+    """List all users (admin only)."""
+    result = await db.execute(select(User).order_by(User.created_at))
+    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
+    ]
+
+
+@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(
+    user_data: UserCreate,
+    current_user: User = RequireAdmin(),
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new user (admin only)."""
+    # Check if username already exists
+    existing_user = await db.execute(select(User).where(User.username == user_data.username))
+    if existing_user.scalar_one_or_none():
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Username already exists",
+        )
+
+    # Validate role
+    if user_data.role not in ["admin", "user"]:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Role must be 'admin' or 'user'",
+        )
+
+    new_user = User(
+        username=user_data.username,
+        password_hash=get_password_hash(user_data.password),
+        role=user_data.role,
+        is_active=True,
+    )
+    db.add(new_user)
+    await db.commit()
+    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(),
+    )
+
+
+@router.get("/{user_id}", response_model=UserResponse)
+async def get_user(
+    user_id: int,
+    current_user: User = RequireAdmin(),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a user by ID (admin only)."""
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_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(),
+    )
+
+
+@router.patch("/{user_id}", response_model=UserResponse)
+async def update_user(
+    user_id: int,
+    user_data: UserUpdate,
+    current_user: User = RequireAdmin(),
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a user (admin only)."""
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    # Prevent deactivating the last admin
+    if user_data.is_active is False and user.role == "admin":
+        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:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Cannot deactivate the last admin user",
+            )
+
+    # Prevent changing role of last admin
+    if user_data.role and user_data.role != "admin" and user.role == "admin":
+        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:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Cannot change role of the last admin user",
+            )
+
+    if user_data.username is not None:
+        # Check if new username already exists
+        existing_user = await db.execute(select(User).where(User.username == user_data.username, User.id != user_id))
+        if existing_user.scalar_one_or_none():
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Username already exists",
+            )
+        user.username = user_data.username
+
+    if user_data.password is not None:
+        user.password_hash = get_password_hash(user_data.password)
+
+    if user_data.role is not None:
+        if user_data.role not in ["admin", "user"]:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Role must be 'admin' or 'user'",
+            )
+        user.role = user_data.role
+
+    if user_data.is_active is not None:
+        user.is_active = user_data.is_active
+
+    await db.commit()
+    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(),
+    )
+
+
+@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
+async def delete_user(
+    user_id: int,
+    current_user: User = RequireAdmin(),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a user (admin only)."""
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    # Prevent deleting the last admin
+    if user.role == "admin":
+        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:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="Cannot delete the last admin user",
+            )
+
+    # Prevent deleting yourself
+    if user.id == current_user.id:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Cannot delete your own account",
+        )
+
+    await db.delete(user)
+    await db.commit()

+ 301 - 58
backend/app/core/auth.py

@@ -1,105 +1,348 @@
-import hashlib
+from __future__ import annotations
+
 import secrets
-from datetime import datetime
+from datetime import datetime, timedelta
+from typing import Annotated
 
-from fastapi import Depends, Header, HTTPException
+from fastapi import Depends, Header, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from jose import JWTError, jwt
+from passlib.context import CryptContext
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.database import get_db
+from backend.app.core.database import async_session, get_db
 from backend.app.models.api_key import APIKey
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
 
+# Password hashing
+# Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
+# pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations
+pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
 
-def generate_api_key() -> tuple[str, str, str]:
-    """Generate a new API key.
+# JWT settings
+SECRET_KEY = "bambuddy-secret-key-change-in-production"  # TODO: Move to settings/env
+ALGORITHM = "HS256"
+ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 7 days
 
-    Returns:
-        Tuple of (full_key, key_hash, key_prefix)
-    """
-    # Generate a random 32-byte key and encode as hex (64 chars)
-    full_key = f"bb_{secrets.token_hex(32)}"
-    key_hash = hashlib.sha256(full_key.encode()).hexdigest()
-    key_prefix = full_key[:11]  # "bb_" + first 8 chars of token
-    return full_key, key_hash, key_prefix
+# HTTP Bearer token
+security = HTTPBearer(auto_error=False)
 
 
-def hash_api_key(key: str) -> str:
-    """Hash an API key for comparison."""
-    return hashlib.sha256(key.encode()).hexdigest()
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+    """Verify a password against a hash.
 
+    Uses pbkdf2_sha256 which handles long passwords automatically.
+    """
+    return pwd_context.verify(plain_password, hashed_password)
 
-async def get_api_key(
-    x_api_key: str = Header(..., alias="X-API-Key"),
-    db: AsyncSession = Depends(get_db),
-) -> APIKey:
-    """Verify API key and return the key record.
 
-    Raises HTTPException if key is invalid, disabled, or expired.
+def get_password_hash(password: str) -> str:
+    """Hash a password.
+
+    Uses pbkdf2_sha256 which is secure and has no password length limit.
     """
-    key_hash = hash_api_key(x_api_key)
+    return pwd_context.hash(password)
 
-    result = await db.execute(select(APIKey).where(APIKey.key_hash == key_hash))
-    api_key = result.scalar_one_or_none()
 
-    if not api_key:
-        raise HTTPException(status_code=401, detail="Invalid API key")
+def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
+    """Create a JWT access token."""
+    to_encode = data.copy()
+    if expires_delta:
+        expire = datetime.utcnow() + expires_delta
+    else:
+        expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+    to_encode.update({"exp": expire})
+    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+    return encoded_jwt
 
-    if not api_key.enabled:
-        raise HTTPException(status_code=403, detail="API key is disabled")
 
-    if api_key.expires_at and api_key.expires_at < datetime.utcnow():
-        raise HTTPException(status_code=403, detail="API key has expired")
+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))
+    return result.scalar_one_or_none()
 
-    # Update last_used timestamp
-    api_key.last_used = datetime.utcnow()
 
-    return api_key
+async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
+    """Authenticate a user by username and password."""
+    user = await get_user_by_username(db, username)
+    if not user:
+        return None
+    if not verify_password(password, user.password_hash):
+        return None
+    if not user.is_active:
+        return None
+    return user
 
 
-async def get_optional_api_key(
-    x_api_key: str | None = Header(None, alias="X-API-Key"),
-    db: AsyncSession = Depends(get_db),
-) -> APIKey | None:
-    """Get API key if provided, return None otherwise."""
-    if not x_api_key:
+async def is_auth_enabled(db: AsyncSession) -> bool:
+    """Check if authentication is enabled."""
+    try:
+        result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
+        setting = result.scalar_one_or_none()
+        if setting is None:
+            return False
+        return setting.value.lower() == "true"
+    except Exception:
+        # If settings table doesn't exist or query fails, assume auth is disabled
+        return False
+
+
+async def get_current_user_optional(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+) -> User | None:
+    """Get the current authenticated user from JWT token, or None if not authenticated."""
+    if credentials is None:
         return None
 
     try:
-        return await get_api_key(x_api_key, db)
-    except HTTPException:
+        token = credentials.credentials
+        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+        username: str = payload.get("sub")
+        if username is None:
+            return None
+    except JWTError:
         return None
 
+    async with async_session() as db:
+        user = await get_user_by_username(db, username)
+        if user is None or not user.is_active:
+            return None
+        return user
+
+
+async def get_current_user(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+) -> User:
+    """Get the current authenticated user from JWT token."""
+    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:
+            raise credentials_exception
+        if not user.is_active:
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="User account is disabled",
+            )
+        return user
+
+
+async def get_current_active_user(current_user: Annotated[User, Depends(get_current_user)]) -> User:
+    """Get the current active user (alias for clarity)."""
+    return current_user
+
+
+async def require_auth_if_enabled(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+) -> User | None:
+    """Require authentication if auth is enabled, otherwise return None."""
+    async with async_session() as db:
+        auth_enabled = await is_auth_enabled(db)
+        if not auth_enabled:
+            return None
+
+        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"},
+            )
+        return user
+
+
+def require_role(required_role: str):
+    """Dependency factory for role-based access control."""
+
+    async def role_checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
+        if current_user.role != required_role:
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=f"Requires {required_role} role",
+            )
+        return current_user
+
+    return role_checker
+
+
+def require_admin_if_auth_enabled():
+    """Dependency factory that requires admin role if auth is enabled."""
+
+    async def admin_checker(
+        current_user: Annotated[User | None, Depends(require_auth_if_enabled)] = None,
+    ) -> User | None:
+        if current_user is None:
+            return None  # Auth not enabled, allow access
+        if current_user.role != "admin":
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="Requires admin role",
+            )
+        return current_user
+
+    return admin_checker
+
+
+def generate_api_key() -> tuple[str, str, str]:
+    """Generate a new API key.
+
+    Returns:
+        tuple: (full_key, key_hash, key_prefix)
+            - full_key: The complete API key (only shown once on creation)
+            - key_hash: Hashed version for storage and verification
+            - key_prefix: First 8 characters for display purposes
+    """
+    # Generate a secure random API key (32 bytes = 64 hex characters)
+    full_key = f"bb_{secrets.token_urlsafe(32)}"
+    key_hash = get_password_hash(full_key)
+    key_prefix = full_key[:8] + "..." if len(full_key) > 8 else full_key
+    return full_key, key_hash, key_prefix
+
+
+async def get_api_key(
+    authorization: Annotated[str | None, Header(alias="Authorization")] = None,
+    x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+    db: AsyncSession = Depends(get_db),
+) -> APIKey:
+    """Get and validate API key from request headers.
+
+    Checks both 'Authorization: Bearer <key>' and 'X-API-Key: <key>' headers.
+    """
+    api_key_value = None
+    if x_api_key:
+        api_key_value = x_api_key
+    elif authorization and authorization.startswith("Bearer "):
+        api_key_value = authorization.replace("Bearer ", "")
+
+    if not api_key_value:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
+        )
+
+    # Get all API keys and check them
+    result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
+    api_keys = result.scalars().all()
+
+    for api_key in api_keys:
+        # Check if key matches (verify against hash)
+        if verify_password(api_key_value, api_key.key_hash):
+            # Check expiration
+            if api_key.expires_at and api_key.expires_at < datetime.now():
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="API key has expired",
+                )
+            # Update last_used timestamp
+            api_key.last_used = datetime.now()
+            await db.commit()
+            return api_key
+
+    raise HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Invalid API key",
+    )
+
 
 def check_permission(api_key: APIKey, permission: str) -> None:
-    """Check if API key has a specific permission.
+    """Check if API key has the required permission.
 
     Args:
-        api_key: The API key record
+        api_key: The API key object
         permission: One of 'queue', 'control_printer', 'read_status'
 
-    Raises HTTPException if permission is denied.
+    Raises:
+        HTTPException: If permission is not granted
     """
     permission_map = {
-        "queue": api_key.can_queue,
-        "control_printer": api_key.can_control_printer,
-        "read_status": api_key.can_read_status,
+        "queue": "can_queue",
+        "control_printer": "can_control_printer",
+        "read_status": "can_read_status",
     }
 
     if permission not in permission_map:
-        raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}")
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=f"Unknown permission: {permission}",
+        )
 
-    if not permission_map[permission]:
-        raise HTTPException(status_code=403, detail=f"API key does not have '{permission}' permission")
+    attr_name = permission_map[permission]
+    if not getattr(api_key, attr_name, False):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail=f"API key does not have '{permission}' permission",
+        )
 
 
 def check_printer_access(api_key: APIKey, printer_id: int) -> None:
-    """Check if API key has access to a specific printer.
+    """Check if API key has access to the specified printer.
 
     Args:
-        api_key: The API key record
-        printer_id: The printer ID to check
+        api_key: The API key object
+        printer_id: The printer ID to check access for
 
-    Raises HTTPException if access is denied.
+    Raises:
+        HTTPException: If access is denied
     """
-    if api_key.printer_ids is not None and printer_id not in api_key.printer_ids:
-        raise HTTPException(status_code=403, detail=f"API key does not have access to printer {printer_id}")
+    # If printer_ids is None or empty, access to all printers
+    if api_key.printer_ids is None or len(api_key.printer_ids) == 0:
+        return
+
+    # Check if printer_id is in allowed list
+    if printer_id not in api_key.printer_ids:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail=f"API key does not have access to printer {printer_id}",
+        )
+
+
+# Convenience dependencies - these are functions that return Depends objects
+def RequireAdmin():
+    """Dependency that requires admin role."""
+    return Depends(require_role("admin"))
+
+
+def RequireAdminIfAuthEnabled():
+    """Dependency that requires admin role if auth is enabled."""
+    return Depends(require_admin_if_auth_enabled())

+ 1 - 1
backend/app/core/config.py

@@ -5,7 +5,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.1.6b10"
+APP_VERSION = "0.1.6b11"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

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

@@ -49,6 +49,7 @@ async def init_db():
         project_bom,
         settings,
         smart_plug,
+        user,
     )
 
     async with engine.begin() as conn:
@@ -641,6 +642,39 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add HA energy sensor entity columns to smart_plugs
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)"))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_total_entity VARCHAR(100)"))
+    except Exception:
+        pass
+
+    # Migration: Create users table for authentication
+    try:
+        await conn.execute(
+            text("""
+            CREATE TABLE IF NOT EXISTS users (
+                id INTEGER PRIMARY KEY,
+                username VARCHAR(100) NOT NULL UNIQUE,
+                password_hash VARCHAR(255) NOT NULL,
+                role VARCHAR(20) NOT NULL DEFAULT 'user',
+                is_active BOOLEAN NOT NULL DEFAULT 1,
+                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_users_username ON users(username)"))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 66 - 11
backend/app/main.py

@@ -54,6 +54,7 @@ from backend.app.api.routes import (
     ams_history,
     api_keys,
     archives,
+    auth,
     camera,
     cloud,
     discovery,
@@ -75,6 +76,7 @@ from backend.app.api.routes import (
     support,
     system,
     updates,
+    users,
     webhook,
     websocket,
 )
@@ -86,6 +88,7 @@ from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.archive import ArchiveService
 from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
 from backend.app.services.bambu_mqtt import PrinterState
+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.print_scheduler import scheduler as print_scheduler
@@ -110,6 +113,22 @@ _expected_prints: dict[tuple[int, str], int] = {}
 _print_energy_start: dict[int, float] = {}
 
 
+async def _get_plug_energy(plug, db) -> dict | None:
+    """Get energy from plug regardless of type (Tasmota or Home Assistant).
+
+    For HA plugs, configures the service with current settings from DB.
+    """
+    if plug.plug_type == "homeassistant":
+        from backend.app.api.routes.settings import get_setting
+
+        ha_url = await get_setting(db, "ha_url") or ""
+        ha_token = await get_setting(db, "ha_token") or ""
+        homeassistant_service.configure(ha_url, ha_token)
+        return await homeassistant_service.get_energy(plug)
+    else:
+        return await tasmota_service.get_energy(plug)
+
+
 def register_expected_print(printer_id: int, filename: str, archive_id: int):
     """Register an expected print from reprint/scheduled so we don't create duplicate archives."""
     # Store with multiple filename variations to catch different naming patterns
@@ -515,7 +534,7 @@ async def on_print_start(printer_id: int, data: dict):
                         f"[ENERGY] Print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}"
                     )
                     if plug:
-                        energy = await tasmota_service.get_energy(plug)
+                        energy = await _get_plug_energy(plug, db)
                         logger.info(f"[ENERGY] Energy response from plug: {energy}")
                         if energy and energy.get("total") is not None:
                             _print_energy_start[archive.id] = energy["total"]
@@ -586,7 +605,7 @@ async def on_print_start(printer_id: int, data: dict):
                         plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
                         plug = plug_result.scalar_one_or_none()
                         if plug:
-                            energy = await tasmota_service.get_energy(plug)
+                            energy = await _get_plug_energy(plug, db)
                             if energy and energy.get("total") is not None:
                                 _print_energy_start[existing_archive.id] = energy["total"]
                                 logger.info(
@@ -777,7 +796,7 @@ async def on_print_start(printer_id: int, data: dict):
                     plug_result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
                     plug = plug_result.scalar_one_or_none()
                     if plug:
-                        energy = await tasmota_service.get_energy(plug)
+                        energy = await _get_plug_energy(plug, db)
                         if energy and energy.get("total") is not None:
                             _print_energy_start[fallback_archive.id] = energy["total"]
                             logger.info(
@@ -846,7 +865,7 @@ async def on_print_start(printer_id: int, data: dict):
                         f"[ENERGY] Auto-archive print start - archive {archive.id}, printer {printer_id}, plug found: {plug is not None}"
                     )
                     if plug:
-                        energy = await tasmota_service.get_energy(plug)
+                        energy = await _get_plug_energy(plug, db)
                         logger.info(f"[ENERGY] Auto-archive energy response: {energy}")
                         if energy and energy.get("total") is not None:
                             _print_energy_start[archive.id] = energy["total"]
@@ -1260,7 +1279,7 @@ async def on_print_complete(printer_id: int, data: dict):
                 plug = plug_result.scalar_one_or_none()
 
                 if plug:
-                    energy = await tasmota_service.get_energy(plug)
+                    energy = await _get_plug_energy(plug, db)
                     logger.info(f"[ENERGY-BG] Energy response: {energy}")
 
                     energy_used = None
@@ -1290,8 +1309,8 @@ async def on_print_complete(printer_id: int, data: dict):
         except Exception as e:
             logger.warning(f"[ENERGY-BG] Failed: {e}")
 
-    async def _background_finish_photo():
-        """Capture finish photo in background."""
+    async def _background_finish_photo() -> str | None:
+        """Capture finish photo in background. Returns photo filename if captured."""
         try:
             logger.info(f"[PHOTO-BG] Starting finish photo capture for archive {archive_id}")
 
@@ -1358,11 +1377,15 @@ async def on_print_complete(printer_id: int, data: dict):
                                 archive.photos = photos
                                 await db.commit()
                                 logger.info(f"[PHOTO-BG] Saved: {photo_filename}")
+                                return photo_filename
+            return None
         except Exception as e:
             logger.warning(f"[PHOTO-BG] Failed: {e}")
+            return None
 
     asyncio.create_task(_background_energy_calculation())
-    asyncio.create_task(_background_finish_photo())  # Skips if camera stream active
+    # Photo capture task - result will be used by notifications
+    photo_task = asyncio.create_task(_background_finish_photo())
     log_timing("Background tasks scheduled (energy, photo)")
 
     # Also run smart plug, notifications, and maintenance as background tasks
@@ -1378,10 +1401,10 @@ async def on_print_complete(printer_id: int, data: dict):
         except Exception as e:
             logger.warning(f"[AUTO-OFF-BG] Failed: {e}")
 
-    async def _background_notifications():
+    async def _background_notifications(finish_photo_filename: str | None = None):
         """Send print complete notifications in background."""
         try:
-            logger.info(f"[NOTIFY-BG] Starting notifications for printer {printer_id}")
+            logger.info(f"[NOTIFY-BG] Starting notifications for printer {printer_id}, photo={finish_photo_filename}")
             async with async_session() as db:
                 from backend.app.models.archive import PrintArchive
                 from backend.app.models.printer import Printer
@@ -1400,6 +1423,21 @@ async def on_print_complete(printer_id: int, data: dict):
                             "actual_filament_grams": archive.filament_used_grams,
                             "failure_reason": archive.failure_reason,
                         }
+                        # Add finish photo URL if available
+                        if finish_photo_filename:
+                            from backend.app.api.routes.settings import get_setting
+
+                            external_url = await get_setting(db, "external_url")
+                            if external_url:
+                                external_url = external_url.rstrip("/")
+                                archive_data["finish_photo_url"] = (
+                                    f"{external_url}/api/v1/archives/{archive_id}/photos/{finish_photo_filename}"
+                                )
+                            else:
+                                # Fallback to relative URL (won't work for external services)
+                                archive_data["finish_photo_url"] = (
+                                    f"/api/v1/archives/{archive_id}/photos/{finish_photo_filename}"
+                                )
 
                 await notification_service.on_print_complete(
                     printer_id, printer_name, print_status, data, db, archive_data=archive_data
@@ -1452,8 +1490,21 @@ async def on_print_complete(printer_id: int, data: dict):
             logger.warning(f"[MAINT-BG] Failed: {e}")
 
     asyncio.create_task(_background_smart_plug())
-    asyncio.create_task(_background_notifications())
     asyncio.create_task(_background_maintenance_check())
+
+    # Notification task waits for photo capture to complete first
+    async def _photo_then_notify():
+        """Wait for photo capture, then send notification with photo URL."""
+        try:
+            finish_photo = await photo_task
+            logger.info(f"[PHOTO-NOTIFY] Photo task returned: {finish_photo}")
+            await _background_notifications(finish_photo)
+        except Exception as e:
+            logger.warning(f"[PHOTO-NOTIFY] Failed: {e}")
+            # Still try to send notification without photo
+            await _background_notifications(None)
+
+    asyncio.create_task(_photo_then_notify())
     log_timing("All background tasks scheduled")
 
     # Auto-scan for timelapse if recording was active during the print
@@ -1884,6 +1935,8 @@ async def lifespan(app: FastAPI):
                 client = await init_spoolman_client(spoolman_url)
                 if await client.health_check():
                     logging.info(f"Auto-connected to Spoolman at {spoolman_url}")
+                    # Ensure the 'tag' extra field exists for RFID/UUID storage
+                    await client.ensure_tag_extra_field()
                 else:
                     logging.warning(f"Spoolman at {spoolman_url} is not reachable")
             except Exception as e:
@@ -1961,6 +2014,8 @@ app = FastAPI(
 )
 
 # API routes
+app.include_router(auth.router, prefix=app_settings.api_prefix)
+app.include_router(users.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(filaments.router, prefix=app_settings.api_prefix)

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

@@ -12,6 +12,7 @@ from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 
 __all__ = [
     "Printer",
@@ -31,4 +32,5 @@ __all__ = [
     "PendingUpload",
     "LibraryFolder",
     "LibraryFile",
+    "User",
 ]

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

@@ -19,6 +19,10 @@ class SmartPlug(Base):
     plug_type: Mapped[str] = mapped_column(String(20), default="tasmota")
     # Home Assistant entity ID (e.g., "switch.printer_plug")
     ha_entity_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
+    # Home Assistant energy sensor entities (optional, for separate energy sensors)
+    ha_power_entity: Mapped[str | None] = mapped_column(String(100), nullable=True)  # sensor.xxx_power
+    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
 
     # Link to printer (1:1)
     printer_id: Mapped[int | None] = mapped_column(

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

@@ -0,0 +1,20 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class User(Base):
+    """User model for authentication and authorization."""
+
+    __tablename__ = "users"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
+    password_hash: Mapped[str] = mapped_column(String(255))
+    role: Mapped[str] = mapped_column(String(20), default="user")  # "admin" or "user"
+    is_active: Mapped[bool] = mapped_column(default=True)
+    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())

+ 47 - 0
backend/app/schemas/auth.py

@@ -0,0 +1,47 @@
+from pydantic import BaseModel
+
+
+class LoginRequest(BaseModel):
+    username: str
+    password: str
+
+
+class LoginResponse(BaseModel):
+    access_token: str
+    token_type: str = "bearer"
+    user: "UserResponse"
+
+
+class UserCreate(BaseModel):
+    username: str
+    password: str
+    role: str = "user"
+
+
+class UserUpdate(BaseModel):
+    username: str | None = None
+    password: str | None = None
+    role: str | None = None
+    is_active: bool | None = None
+
+
+class UserResponse(BaseModel):
+    id: int
+    username: str
+    role: str
+    is_active: bool
+    created_at: str
+
+    class Config:
+        from_attributes = True
+
+
+class SetupRequest(BaseModel):
+    auth_enabled: bool
+    admin_username: str | None = None
+    admin_password: str | None = None
+
+
+class SetupResponse(BaseModel):
+    auth_enabled: bool
+    admin_created: bool | None = None

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

@@ -235,3 +235,30 @@ class AddToQueueResponse(BaseModel):
 
     added: list[AddToQueueResult]
     errors: list[AddToQueueError]
+
+
+# ============ ZIP Extraction ============
+
+
+class ZipExtractResult(BaseModel):
+    """Result for a single file extracted from ZIP."""
+
+    filename: str
+    file_id: int
+    folder_id: int | None = None
+
+
+class ZipExtractError(BaseModel):
+    """Error for a file that couldn't be extracted."""
+
+    filename: str
+    error: str
+
+
+class ZipExtractResponse(BaseModel):
+    """Schema for ZIP extraction response."""
+
+    extracted: int
+    folders_created: int
+    files: list[ZipExtractResult]
+    errors: list[ZipExtractError]

+ 14 - 3
backend/app/schemas/notification_template.py

@@ -26,9 +26,17 @@ class EventType(str, Enum):
 # Available variables for each event type
 EVENT_VARIABLES: dict[str, list[str]] = {
     "print_start": ["printer", "filename", "estimated_time", "timestamp", "app_name"],
-    "print_complete": ["printer", "filename", "duration", "filament_grams", "timestamp", "app_name"],
-    "print_failed": ["printer", "filename", "duration", "reason", "timestamp", "app_name"],
-    "print_stopped": ["printer", "filename", "duration", "timestamp", "app_name"],
+    "print_complete": [
+        "printer",
+        "filename",
+        "duration",
+        "filament_grams",
+        "finish_photo_url",
+        "timestamp",
+        "app_name",
+    ],
+    "print_failed": ["printer", "filename", "duration", "reason", "finish_photo_url", "timestamp", "app_name"],
+    "print_stopped": ["printer", "filename", "duration", "finish_photo_url", "timestamp", "app_name"],
     "print_progress": ["printer", "filename", "progress", "remaining_time", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
@@ -53,6 +61,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "filename": "Benchy.3mf",
         "duration": "1h 18m",
         "filament_grams": "15.2",
+        "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_154800_abc12345.jpg",
         "timestamp": "2024-01-15 15:48",
         "app_name": "Bambuddy",
     },
@@ -61,6 +70,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "filename": "Benchy.3mf",
         "duration": "0h 45m",
         "reason": "Filament runout",
+        "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_151500_def67890.jpg",
         "timestamp": "2024-01-15 15:15",
         "app_name": "Bambuddy",
     },
@@ -68,6 +78,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "duration": "0h 30m",
+        "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_150000_ghi11223.jpg",
         "timestamp": "2024-01-15 15:00",
         "app_name": "Bambuddy",
     },

+ 6 - 0
backend/app/schemas/settings.py

@@ -90,6 +90,11 @@ class AppSettings(BaseModel):
     mqtt_topic_prefix: str = Field(default="bambuddy", description="Topic prefix for all published messages")
     mqtt_use_tls: bool = Field(default=False, description="Use TLS/SSL encryption for MQTT connection")
 
+    # External URL for notifications
+    external_url: str = Field(
+        default="", description="External URL where Bambuddy is accessible (for notification images)"
+    )
+
     # Home Assistant integration for smart plug control
     ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
     ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
@@ -156,6 +161,7 @@ class AppSettingsUpdate(BaseModel):
     mqtt_password: str | None = None
     mqtt_topic_prefix: str | None = None
     mqtt_use_tls: bool | None = None
+    external_url: str | None = None
     ha_enabled: bool | None = None
     ha_url: str | None = None
     ha_token: str | None = None

+ 17 - 0
backend/app/schemas/smart_plug.py

@@ -15,6 +15,10 @@ class SmartPlugBase(BaseModel):
 
     # 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_]+$")
+    # 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_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_]+$")
 
     printer_id: int | None = None
     enabled: bool = True
@@ -52,6 +56,10 @@ class SmartPlugUpdate(BaseModel):
     plug_type: Literal["tasmota", "homeassistant"] | None = None
     ip_address: str | None = None
     ha_entity_id: str | None = None
+    # Home Assistant energy sensor entities (optional)
+    ha_power_entity: str | None = None
+    ha_energy_today_entity: str | None = None
+    ha_energy_total_entity: str | None = None
     printer_id: int | None = None
     enabled: bool | None = None
     auto_on: bool | None = None
@@ -140,3 +148,12 @@ class HAEntity(BaseModel):
     friendly_name: str
     state: str | None = None
     domain: str  # "switch", "light", "input_boolean"
+
+
+class HASensorEntity(BaseModel):
+    """A Home Assistant sensor entity for energy monitoring."""
+
+    entity_id: str
+    friendly_name: str
+    state: str | None = None
+    unit_of_measurement: str | None = None  # "W", "kW", "kWh", "Wh"

+ 5 - 2
backend/app/services/archive.py

@@ -844,8 +844,11 @@ class ArchiveService:
             if filament:
                 cost = round((filament_grams / 1000) * filament.cost_per_kg, 2)
             else:
-                # Default cost_per_kg if filament type not found
-                default_cost_per_kg = 25.0
+                # Use default filament cost from settings
+                from backend.app.api.routes.settings import get_setting
+
+                default_cost_setting = await get_setting(self.db, "default_filament_cost")
+                default_cost_per_kg = float(default_cost_setting) if default_cost_setting else 25.0
                 cost = round((filament_grams / 1000) * default_cost_per_kg, 2)
 
         # Calculate quantity from printable objects count

+ 14 - 1
backend/app/services/camera.py

@@ -66,12 +66,25 @@ def supports_rtsp(model: str | None) -> bool:
 
     RTSP supported: X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S
     Chamber image only: A1, A1MINI, P1P, P1S
+
+    Note: Model can be either display name (e.g., "P2S") or internal code (e.g., "N7").
+    Internal codes from MQTT/SSDP:
+      - BL-P001: X1/X1C
+      - C13: X1E
+      - O1D: H2D
+      - O1C: H2C
+      - O1S: H2S
+      - O1E: H2D Pro
+      - N7: P2S
     """
     if model:
         model_upper = model.upper()
-        # These models support RTSP on port 322
+        # Display names: X1, X1C, X1E, H2C, H2D, H2DPRO, H2S, P2S
         if model_upper.startswith(("X1", "H2", "P2")):
             return True
+        # Internal codes for RTSP models
+        if model_upper in ("BL-P001", "C13", "O1D", "O1C", "O1S", "O1E", "N7"):
+            return True
     # A1/P1 and unknown models use chamber image protocol
     return False
 

+ 120 - 21
backend/app/services/homeassistant.py

@@ -110,34 +110,56 @@ class HomeAssistantService:
             return False
 
     async def get_energy(self, plug: "SmartPlug") -> dict | None:
-        """Get energy data from HA entity attributes.
+        """Get energy data from HA sensor entities or switch attributes.
 
-        HA entities may have power attributes - check common patterns.
+        First tries dedicated sensor entities if configured, then falls back
+        to checking the switch entity's attributes.
         Returns dict with energy data or None if not available.
         """
         if not self.base_url or not self.token:
             return None
 
+        power = None
+        today = None
+        total = None
+
         try:
             async with httpx.AsyncClient(timeout=self.timeout) as client:
-                response = await client.get(
-                    f"{self.base_url}/api/states/{plug.ha_entity_id}",
-                    headers=self._headers(),
-                )
-                response.raise_for_status()
-                attrs = response.json().get("attributes", {})
+                # Fetch power from dedicated sensor entity if configured
+                if plug.ha_power_entity:
+                    power = await self._get_sensor_value(client, plug.ha_power_entity)
+
+                # Fetch today's energy from dedicated sensor entity if configured
+                if plug.ha_energy_today_entity:
+                    today = await self._get_sensor_value(client, plug.ha_energy_today_entity)
+
+                # Fetch total energy from dedicated sensor entity if configured
+                if plug.ha_energy_total_entity:
+                    total = await self._get_sensor_value(client, plug.ha_energy_total_entity)
+
+                # Fallback: try switch entity attributes (original behavior)
+                if power is None:
+                    response = await client.get(
+                        f"{self.base_url}/api/states/{plug.ha_entity_id}",
+                        headers=self._headers(),
+                    )
+                    response.raise_for_status()
+                    attrs = response.json().get("attributes", {})
+                    power = attrs.get("current_power_w") or attrs.get("power")
+                    if today is None:
+                        today = attrs.get("today_energy_kwh")
+                    if total is None:
+                        total = attrs.get("total_energy_kwh")
 
-                # Common HA power monitoring attributes
-                power = attrs.get("current_power_w") or attrs.get("power")
                 if power is None:
                     return None
 
                 return {
                     "power": power,
-                    "voltage": attrs.get("voltage"),
-                    "current": attrs.get("current"),
-                    "today": attrs.get("today_energy_kwh"),
-                    "total": attrs.get("total_energy_kwh"),
+                    "voltage": None,
+                    "current": None,
+                    "today": today,
+                    "total": total,
                     "yesterday": None,
                     "factor": None,
                     "apparent_power": None,
@@ -146,6 +168,21 @@ class HomeAssistantService:
         except Exception:
             return None
 
+    async def _get_sensor_value(self, client: httpx.AsyncClient, entity_id: str) -> float | None:
+        """Fetch numeric value from a HA sensor entity."""
+        try:
+            response = await client.get(
+                f"{self.base_url}/api/states/{entity_id}",
+                headers=self._headers(),
+            )
+            response.raise_for_status()
+            state = response.json().get("state")
+            if state and state not in ("unknown", "unavailable"):
+                return float(state)
+        except Exception:
+            pass
+        return None
+
     async def test_connection(self, url: str, token: str) -> dict:
         """Test connection to Home Assistant.
 
@@ -178,8 +215,11 @@ class HomeAssistantService:
         except Exception as e:
             return {"success": False, "message": None, "error": str(e)}
 
-    async def list_entities(self, url: str, token: str) -> list[dict]:
-        """List available switch/light entities from HA.
+    async def list_entities(self, url: str, token: str, search: str | None = None) -> list[dict]:
+        """List available entities from HA.
+
+        By default, returns switch/light/input_boolean domains.
+        When search is provided, searches ALL entities by entity_id or friendly_name.
 
         Returns list of entity dicts with:
             - entity_id: str
@@ -187,6 +227,53 @@ class HomeAssistantService:
             - state: str
             - domain: str
         """
+        # Default domains for smart plug control
+        default_domains = {"switch", "light", "input_boolean"}
+
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.get(
+                    f"{url.rstrip('/')}/api/states",
+                    headers={"Authorization": f"Bearer {token}"},
+                )
+                response.raise_for_status()
+
+                entities = []
+                search_lower = search.lower().strip() if search else None
+
+                for entity in response.json():
+                    entity_id = entity.get("entity_id", "")
+                    domain = entity_id.split(".")[0] if "." in entity_id else ""
+                    friendly_name = entity.get("attributes", {}).get("friendly_name", entity_id)
+
+                    # If searching, match against entity_id or friendly_name
+                    if search_lower:
+                        if search_lower not in entity_id.lower() and search_lower not in friendly_name.lower():
+                            continue
+                    else:
+                        # No search: filter to default domains only
+                        if domain not in default_domains:
+                            continue
+
+                    entities.append(
+                        {
+                            "entity_id": entity_id,
+                            "friendly_name": friendly_name,
+                            "state": entity.get("state"),
+                            "domain": domain,
+                        }
+                    )
+
+                return sorted(entities, key=lambda x: x["friendly_name"].lower())
+        except Exception as e:
+            logger.warning(f"Failed to list HA entities: {e}")
+            return []
+
+    async def list_sensor_entities(self, url: str, token: str) -> list[dict]:
+        """List available sensor entities for energy monitoring.
+
+        Returns list of sensor entities with power/energy units.
+        """
         try:
             async with httpx.AsyncClient(timeout=self.timeout) as client:
                 response = await client.get(
@@ -195,25 +282,37 @@ class HomeAssistantService:
                 )
                 response.raise_for_status()
 
+                # Valid units for energy monitoring sensors
+                power_units = {"W", "kW", "mW"}
+                energy_units = {"kWh", "Wh", "MWh"}
+                valid_units = power_units | energy_units
+
                 entities = []
                 for entity in response.json():
                     entity_id = entity.get("entity_id", "")
                     domain = entity_id.split(".")[0] if "." in entity_id else ""
 
-                    # Filter to switch, light, input_boolean domains
-                    if domain in ["switch", "light", "input_boolean"]:
+                    # Filter to sensor domain only
+                    if domain != "sensor":
+                        continue
+
+                    attrs = entity.get("attributes", {})
+                    unit = attrs.get("unit_of_measurement", "")
+
+                    # Only include sensors with power/energy units
+                    if unit in valid_units:
                         entities.append(
                             {
                                 "entity_id": entity_id,
-                                "friendly_name": entity.get("attributes", {}).get("friendly_name", entity_id),
+                                "friendly_name": attrs.get("friendly_name", entity_id),
                                 "state": entity.get("state"),
-                                "domain": domain,
+                                "unit_of_measurement": unit,
                             }
                         )
 
                 return sorted(entities, key=lambda x: x["friendly_name"].lower())
         except Exception as e:
-            logger.warning(f"Failed to list HA entities: {e}")
+            logger.warning(f"Failed to list HA sensor entities: {e}")
             return []
 
 

+ 30 - 11
backend/app/services/notification_service.py

@@ -251,11 +251,17 @@ class NotificationService:
             return False, "Bot token and chat ID are required"
 
         url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
+
+        # Check if message contains URLs (which have underscores that break Markdown)
+        # If so, don't use parse_mode to avoid parsing errors
+        has_url = "http://" in message or "https://" in message
+
         data = {
             "chat_id": chat_id,
             "text": message,
-            "parse_mode": "Markdown",
         }
+        if not has_url:
+            data["parse_mode"] = "Markdown"
 
         client = await self._get_client()
         response = await client.post(url, json=data)
@@ -350,22 +356,33 @@ class NotificationService:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
     async def _send_webhook(self, config: dict, title: str, message: str) -> tuple[bool, str]:
-        """Send notification via generic webhook (POST JSON)."""
+        """Send notification via generic webhook (POST JSON).
+
+        Supports two payload formats:
+        - generic: Custom field names with timestamp/source metadata
+        - slack: Slack/Mattermost compatible format (just {"text": "..."})
+        """
         webhook_url = config.get("webhook_url", "").strip()
         auth_header = config.get("auth_header", "").strip()
-        custom_field_title = config.get("field_title", "title").strip() or "title"
-        custom_field_message = config.get("field_message", "message").strip() or "message"
+        payload_format = config.get("payload_format", "generic").strip()
 
         if not webhook_url:
             return False, "Webhook URL is required"
 
-        # Build payload with custom field names
-        data = {
-            custom_field_title: title,
-            custom_field_message: message,
-            "timestamp": datetime.now().isoformat(),
-            "source": "Bambuddy",
-        }
+        # Build payload based on format
+        if payload_format == "slack":
+            # Slack/Mattermost format - just text field
+            data = {"text": f"*{title}*\n{message}"}
+        else:
+            # Generic format with custom field names
+            custom_field_title = config.get("field_title", "title").strip() or "title"
+            custom_field_message = config.get("field_message", "message").strip() or "message"
+            data = {
+                custom_field_title: title,
+                custom_field_message: message,
+                "timestamp": datetime.now().isoformat(),
+                "source": "Bambuddy",
+            }
 
         headers = {"Content-Type": "application/json"}
         if auth_header:
@@ -667,6 +684,8 @@ class NotificationService:
                 variables["filament_grams"] = f"{archive_data['actual_filament_grams']:.1f}"
             if status == "failed" and archive_data.get("failure_reason"):
                 variables["reason"] = archive_data["failure_reason"]
+            if archive_data.get("finish_photo_url"):
+                variables["finish_photo_url"] = archive_data["finish_photo_url"]
 
         logger.info(f"on_print_complete variables: {variables}, archive_data: {archive_data}")
 

+ 10 - 0
backend/app/services/printer_manager.py

@@ -10,8 +10,10 @@ from backend.app.services.bambu_mqtt import BambuMQTTClient, MQTTLogEntry, Print
 # Models that have a real chamber temperature sensor
 # Based on Home Assistant Bambu Lab integration
 # P1P/P1S and A1/A1Mini do NOT have chamber temp sensors
+# Includes both display names and internal codes from MQTT/SSDP
 CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
     [
+        # Display names
         "X1",
         "X1C",
         "X1E",  # X1 series
@@ -20,6 +22,14 @@ CHAMBER_TEMP_SUPPORTED_MODELS = frozenset(
         "H2D",
         "H2DPRO",
         "H2S",  # H2 series
+        # Internal codes (from MQTT/SSDP)
+        "BL-P001",  # X1/X1C
+        "C13",  # X1E
+        "O1D",  # H2D
+        "O1C",  # H2C
+        "O1S",  # H2S
+        "O1E",  # H2D Pro
+        "N7",  # P2S
     ]
 )
 

+ 36 - 0
backend/app/services/spoolman.py

@@ -488,6 +488,42 @@ class SpoolmanClient:
         vendor = await self.create_vendor("Bambu Lab")
         return vendor["id"] if vendor else None
 
+    async def ensure_tag_extra_field(self) -> bool:
+        """Ensure the 'tag' extra field exists for spools.
+
+        Spoolman requires extra fields to be registered before use.
+        This creates the 'tag' field used to store RFID/UUID identifiers.
+
+        Returns:
+            True if field exists or was created, False on failure.
+        """
+        try:
+            client = await self._get_client()
+
+            # Check if field already exists
+            response = await client.get(f"{self.api_url}/field/spool/tag")
+            if response.status_code == 200:
+                logger.debug("Spoolman 'tag' extra field already exists")
+                return True
+
+            # Field doesn't exist - create it
+            field_data = {
+                "name": "tag",
+                "field_type": "text",
+                "default_value": None,
+            }
+            response = await client.post(f"{self.api_url}/field/spool/tag", json=field_data)
+            if response.status_code in (200, 201):
+                logger.info("Created 'tag' extra field in Spoolman")
+                return True
+
+            logger.warning(f"Failed to create 'tag' extra field: {response.status_code} - {response.text}")
+            return False
+
+        except Exception as e:
+            logger.warning(f"Failed to ensure 'tag' extra field exists: {e}")
+            return False
+
     def parse_ams_tray(self, ams_id: int, tray_data: dict) -> AMSTray | None:
         """Parse AMS tray data into AMSTray object.
 

+ 3 - 1
backend/tests/conftest.py

@@ -59,6 +59,7 @@ async def test_engine():
         project,
         settings,
         smart_plug,
+        user,
     )
 
     async with engine.begin() as conn:
@@ -98,9 +99,10 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
     async def mock_init_printer_connections(db):
         pass  # No-op - don't connect to real printers
 
-    # Also patch the module-level async_session used by services
+    # Also patch the module-level async_session used by services and auth
     with (
         patch("backend.app.core.database.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),
     ):
         async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:

+ 362 - 0
backend/tests/integration/test_auth_api.py

@@ -0,0 +1,362 @@
+"""Integration tests for Authentication API endpoints.
+
+Tests the full request/response cycle for /api/v1/auth/ and /api/v1/users/ endpoints.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestAuthStatusAPI:
+    """Integration tests for /api/v1/auth/status endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_auth_status_disabled(self, async_client: AsyncClient):
+        """Verify auth status returns disabled when not configured."""
+        response = await async_client.get("/api/v1/auth/status")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "auth_enabled" in result
+        assert result["auth_enabled"] is False
+        assert result["requires_setup"] is True
+
+
+class TestAuthSetupAPI:
+    """Integration tests for /api/v1/auth/setup endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_auth_disabled(self, async_client: AsyncClient):
+        """Verify auth can be set up with auth disabled (no password required)."""
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": False},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auth_enabled"] is False
+        assert result["admin_created"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_auth_enabled_requires_credentials(self, async_client: AsyncClient):
+        """Verify enabling auth requires admin username and password."""
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": True},
+        )
+
+        assert response.status_code == 400
+        assert "Admin username and password are required" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setup_auth_enabled_with_credentials(self, async_client: AsyncClient):
+        """Verify auth can be enabled with admin credentials."""
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "testadmin",
+                "admin_password": "testpassword123",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["auth_enabled"] is True
+        assert result["admin_created"] is True
+
+
+class TestAuthLoginAPI:
+    """Integration tests for /api/v1/auth/login endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_auth_disabled(self, async_client: AsyncClient):
+        """Verify login fails when auth is not enabled."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "admin", "password": "password"},
+        )
+
+        assert response.status_code == 400
+        assert "Authentication is not enabled" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_success(self, async_client: AsyncClient):
+        """Verify login succeeds with valid credentials after setup."""
+        # First enable auth
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "logintest",
+                "admin_password": "loginpassword123",
+            },
+        )
+
+        # Now login
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "logintest", "password": "loginpassword123"},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "access_token" in result
+        assert result["token_type"] == "bearer"
+        assert result["user"]["username"] == "logintest"
+        assert result["user"]["role"] == "admin"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_invalid_credentials(self, async_client: AsyncClient):
+        """Verify login fails with invalid credentials."""
+        # First enable auth
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "invalidtest",
+                "admin_password": "correctpassword",
+            },
+        )
+
+        # Try login with wrong password
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "invalidtest", "password": "wrongpassword"},
+        )
+
+        assert response.status_code == 401
+        assert "Incorrect username or password" in response.json()["detail"]
+
+
+class TestAuthMeAPI:
+    """Integration tests for /api/v1/auth/me endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_without_token(self, async_client: AsyncClient):
+        """Verify /me fails without authentication token."""
+        response = await async_client.get("/api/v1/auth/me")
+
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_me_with_valid_token(self, async_client: AsyncClient):
+        """Verify /me returns user info with valid token."""
+        # Setup and login
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "metest",
+                "admin_password": "mepassword123",
+            },
+        )
+
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "metest", "password": "mepassword123"},
+        )
+        token = login_response.json()["access_token"]
+
+        # Get current user
+        response = await async_client.get(
+            "/api/v1/auth/me",
+            headers={"Authorization": f"Bearer {token}"},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["username"] == "metest"
+        assert result["role"] == "admin"
+        assert result["is_active"] is True
+
+
+class TestUsersAPI:
+    """Integration tests for /api/v1/users/ 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": "usersadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "usersadmin", "password": "adminpassword123"},
+        )
+        return login_response.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_users_requires_auth(self, async_client: AsyncClient):
+        """Verify listing users requires authentication."""
+        response = await async_client.get("/api/v1/users/")
+
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_users_as_admin(self, async_client: AsyncClient, auth_token: str):
+        """Verify admin can list users."""
+        response = await async_client.get(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert isinstance(result, list)
+        assert len(result) >= 1  # At least the admin user
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user(self, async_client: AsyncClient, auth_token: str):
+        """Verify admin can create a new user."""
+        response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "newuser",
+                "password": "newuserpassword",
+                "role": "user",
+            },
+        )
+
+        assert response.status_code == 201
+        result = response.json()
+        assert result["username"] == "newuser"
+        assert result["role"] == "user"
+        assert result["is_active"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user_duplicate_username(self, async_client: AsyncClient, auth_token: str):
+        """Verify creating user with duplicate username fails."""
+        # Create first user
+        await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "duplicateuser",
+                "password": "password123",
+                "role": "user",
+            },
+        )
+
+        # Try to create duplicate
+        response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "duplicateuser",
+                "password": "password456",
+                "role": "user",
+            },
+        )
+
+        assert response.status_code == 400
+        assert "Username already exists" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_user(self, async_client: AsyncClient, auth_token: str):
+        """Verify admin can update a user."""
+        # Create user
+        create_response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "updateuser",
+                "password": "password123",
+                "role": "user",
+            },
+        )
+        user_id = create_response.json()["id"]
+
+        # Update user
+        response = await async_client.patch(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={"role": "admin"},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["role"] == "admin"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_user(self, async_client: AsyncClient, auth_token: str):
+        """Verify admin can delete a user."""
+        # Create user
+        create_response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "deleteuser",
+                "password": "password123",
+                "role": "user",
+            },
+        )
+        user_id = create_response.json()["id"]
+
+        # Delete user
+        response = await async_client.delete(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 204
+
+
+class TestAuthDisableAPI:
+    """Integration tests for /api/v1/auth/disable endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_auth(self, async_client: AsyncClient):
+        """Verify admin can disable authentication."""
+        # Setup auth
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "disableadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        # Login to get token
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "disableadmin", "password": "adminpassword123"},
+        )
+        token = login_response.json()["access_token"]
+
+        # Disable auth
+        response = await async_client.post(
+            "/api/v1/auth/disable",
+            headers={"Authorization": f"Bearer {token}"},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["auth_enabled"] is False
+
+        # Verify auth is now disabled
+        status_response = await async_client.get("/api/v1/auth/status")
+        assert status_response.json()["auth_enabled"] is False

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

@@ -342,3 +342,106 @@ class TestLibraryAddToQueueAPI:
         assert len(result["added"]) == 0
         assert len(result["errors"]) == 1
         assert "sliced" in result["errors"][0]["error"].lower()
+
+
+class TestLibraryZipExtractAPI:
+    """Integration tests for ZIP extraction endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_invalid_file_type(self, async_client: AsyncClient, db_session):
+        """Verify non-ZIP files are rejected."""
+        # Create a fake file that's not a ZIP
+        files = {"file": ("test.txt", b"This is not a zip file", "text/plain")}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
+        assert response.status_code == 400
+        assert "ZIP" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_basic(self, async_client: AsyncClient, db_session):
+        """Verify basic ZIP extraction works."""
+        import io
+        import zipfile
+
+        # Create a simple ZIP file in memory
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("test1.txt", "Content of file 1")
+            zf.writestr("test2.txt", "Content of file 2")
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["extracted"] == 2
+        assert len(result["files"]) == 2
+        assert len(result["errors"]) == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_with_folders(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction preserves folder structure."""
+        import io
+        import zipfile
+
+        # Create a ZIP file with folder structure
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("folder1/file1.txt", "Content 1")
+            zf.writestr("folder1/subfolder/file2.txt", "Content 2")
+            zf.writestr("folder2/file3.txt", "Content 3")
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        params = {"preserve_structure": "true"}
+        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"] == 3
+        assert result["folders_created"] >= 3  # folder1, folder1/subfolder, folder2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_flat(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction can extract flat (no folders)."""
+        import io
+        import zipfile
+
+        # Create a ZIP file with folder structure
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("folder/file1.txt", "Content 1")
+            zf.writestr("folder/file2.txt", "Content 2")
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        params = {"preserve_structure": "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"] == 2
+        assert result["folders_created"] == 0  # No folders created when flat
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extract_zip_skips_macos_files(self, async_client: AsyncClient, db_session):
+        """Verify ZIP extraction skips __MACOSX and hidden files."""
+        import io
+        import zipfile
+
+        # Create a ZIP file with macOS junk files
+        zip_buffer = io.BytesIO()
+        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("real_file.txt", "Real content")
+            zf.writestr("__MACOSX/._real_file.txt", "macOS metadata")
+            zf.writestr(".hidden_file", "Hidden content")
+        zip_buffer.seek(0)
+
+        files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
+        response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["extracted"] == 1  # Only real_file.txt
+        assert result["files"][0]["filename"] == "real_file.txt"

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

@@ -521,3 +521,55 @@ class TestSmartPlugsAPI:
         result = response.json()
         assert result["state"] == "ON"
         assert result["reachable"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_homeassistant_plug_with_energy_sensors(self, async_client: AsyncClient):
+        """Verify HA plug can be created with energy sensor entities."""
+        data = {
+            "name": "HA Plug with Energy",
+            "plug_type": "homeassistant",
+            "ha_entity_id": "switch.printer_plug",
+            "ha_power_entity": "sensor.printer_power",
+            "ha_energy_today_entity": "sensor.printer_energy_today",
+            "ha_energy_total_entity": "sensor.printer_energy_total",
+            "enabled": True,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ha_power_entity"] == "sensor.printer_power"
+        assert result["ha_energy_today_entity"] == "sensor.printer_energy_today"
+        assert result["ha_energy_total_entity"] == "sensor.printer_energy_total"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_ha_energy_sensor_entities(self, async_client: AsyncClient, smart_plug_factory, db_session):
+        """Verify HA energy sensor entities can be updated."""
+        plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="switch.test")
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "ha_power_entity": "sensor.new_power",
+                "ha_energy_today_entity": "sensor.new_today",
+                "ha_energy_total_entity": "sensor.new_total",
+            },
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ha_power_entity"] == "sensor.new_power"
+        assert result["ha_energy_today_entity"] == "sensor.new_today"
+        assert result["ha_energy_total_entity"] == "sensor.new_total"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ha_sensors_endpoint_not_configured(self, async_client: AsyncClient):
+        """Verify HA sensors endpoint returns error when not configured."""
+        response = await async_client.get("/api/v1/smart-plugs/ha/sensors")
+
+        assert response.status_code == 400
+        assert "not configured" in response.json()["detail"].lower()

+ 1 - 0
backend/tests/integration/test_spoolman_api.py

@@ -39,6 +39,7 @@ class TestSpoolmanAPI:
         mock_client.is_connected = True
         mock_client.base_url = "http://localhost:7912"
         mock_client.health_check = AsyncMock(return_value=True)
+        mock_client.ensure_tag_extra_field = AsyncMock(return_value=True)
         mock_client.get_spools = AsyncMock(return_value=[])
         mock_client.get_filaments = AsyncMock(return_value=[])
         mock_client.create_spool = AsyncMock(return_value={"id": 1})

+ 71 - 0
backend/tests/unit/services/test_notification_service.py

@@ -557,6 +557,38 @@ class TestNotificationProviderTypes:
             assert success is False
             assert "Connection failed" in message or "error" in message.lower()
 
+    @pytest.mark.asyncio
+    async def test_webhook_slack_format_sends_text_only(self, service):
+        """Verify Slack/Mattermost format sends only text field."""
+        config = {
+            "webhook_url": "http://mattermost.local/hooks/abc123",
+            "payload_format": "slack",
+        }
+
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+
+            success, message = await service._send_webhook(config, "Test Title", "Test Message")
+
+            assert success is True
+            mock_client.post.assert_called_once()
+
+            # Verify payload format is Slack-compatible
+            call_args = mock_client.post.call_args
+            payload = call_args.kwargs.get("json") or call_args[1].get("json")
+            assert "text" in payload
+            assert "*Test Title*" in payload["text"]
+            assert "Test Message" in payload["text"]
+            # Should NOT have generic fields
+            assert "timestamp" not in payload
+            assert "source" not in payload
+
 
 class TestNotificationVariableFallbacks:
     """Tests for notification variable fallback values."""
@@ -651,6 +683,45 @@ class TestNotificationVariableFallbacks:
             if captured_variables.get("duration"):
                 assert captured_variables["duration"] != "Unknown"
 
+    @pytest.mark.asyncio
+    async def test_print_complete_with_finish_photo_url(self, service):
+        """Verify finish_photo_url is passed through from archive_data."""
+        mock_db = AsyncMock()
+        mock_provider = MagicMock()
+        mock_provider.id = 1
+
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_print_complete(
+                printer_id=1,
+                printer_name="Test",
+                status="completed",
+                data={"subtask_name": "test_print"},
+                db=mock_db,
+                archive_data={
+                    "print_time_seconds": 3600,
+                    "actual_filament_grams": 50.5,
+                    "finish_photo_url": "http://localhost:8000/api/v1/archives/1/photos/finish_test.jpg",
+                },
+            )
+
+            # finish_photo_url should be passed through to template variables
+            assert (
+                captured_variables.get("finish_photo_url")
+                == "http://localhost:8000/api/v1/archives/1/photos/finish_test.jpg"
+            )
+
     @pytest.mark.asyncio
     async def test_print_start_estimated_time_fallback(self, service):
         """Verify estimated time shows 'Unknown' when not available."""

+ 28 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -839,6 +839,34 @@ class TestSupportsChamberTemp:
         assert supports_chamber_temp("X1c") is True
         assert supports_chamber_temp("p1s") is False
 
+    def test_internal_model_codes_supported(self):
+        """Verify internal model codes from MQTT/SSDP are recognized."""
+        # X1/X1C
+        assert supports_chamber_temp("BL-P001") is True
+        # X1E
+        assert supports_chamber_temp("C13") is True
+        # H2D
+        assert supports_chamber_temp("O1D") is True
+        # H2C
+        assert supports_chamber_temp("O1C") is True
+        # H2S
+        assert supports_chamber_temp("O1S") is True
+        # H2D Pro
+        assert supports_chamber_temp("O1E") is True
+        # P2S
+        assert supports_chamber_temp("N7") is True
+
+    def test_internal_model_codes_not_supported(self):
+        """Verify A1/P1 internal codes are NOT supported."""
+        # P1P
+        assert supports_chamber_temp("C11") is False
+        # P1S
+        assert supports_chamber_temp("C12") is False
+        # A1
+        assert supports_chamber_temp("N2S") is False
+        # A1 Mini
+        assert supports_chamber_temp("N1") is False
+
 
 class TestGetDerivedStatusName:
     """Tests for get_derived_status_name function."""

+ 3 - 3
docker-publish.sh

@@ -75,9 +75,9 @@ fi
 echo -e "${GREEN}================================================${NC}"
 echo ""
 
-# Check if logged in to registry
-if ! docker info 2>/dev/null | grep -q "Username"; then
-    echo -e "${YELLOW}Warning: You may not be logged in to Docker registry${NC}"
+# Check if logged in to ghcr.io
+if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then
+    echo -e "${YELLOW}Warning: You may not be logged in to ghcr.io${NC}"
     echo "Run: echo \$GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin"
     echo ""
 fi

+ 92 - 23
frontend/src/App.tsx

@@ -1,4 +1,4 @@
-import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { Layout } from './components/Layout';
 import { PrintersPage } from './pages/PrintersPage';
@@ -14,9 +14,13 @@ import { FileManagerPage } from './pages/FileManagerPage';
 import { CameraPage } from './pages/CameraPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
+import { LoginPage } from './pages/LoginPage';
+import { SetupPage } from './pages/SetupPage';
+import { UsersPage } from './pages/UsersPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
+import { AuthProvider, useAuth } from './contexts/AuthContext';
 
 const queryClient = new QueryClient({
   defaultOptions: {
@@ -32,33 +36,98 @@ function WebSocketProvider({ children }: { children: React.ReactNode }) {
   return <>{children}</>;
 }
 
+function ProtectedRoute({ children }: { children: React.ReactNode }) {
+  const { authEnabled, loading, user } = useAuth();
+
+  if (loading) {
+    return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
+  }
+
+  if (authEnabled && !user) {
+    return <Navigate to="/login" replace />;
+  }
+
+  return <>{children}</>;
+}
+
+function AdminRoute({ children }: { children: React.ReactNode }) {
+  const { authEnabled, loading, user } = useAuth();
+
+  if (loading) {
+    return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
+  }
+
+  // If auth is not enabled, allow access (backward compatibility)
+  if (!authEnabled) {
+    return <>{children}</>;
+  }
+
+  // If auth is enabled but no user, redirect to login
+  if (!user) {
+    return <Navigate to="/login" replace />;
+  }
+
+  // If user is not admin, redirect to home
+  if (user.role !== 'admin') {
+    return <Navigate to="/" replace />;
+  }
+
+  return <>{children}</>;
+}
+
+function SetupRoute({ children }: { children: React.ReactNode }) {
+  const { authEnabled, loading } = useAuth();
+
+  if (loading) {
+    return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
+  }
+
+  // If auth is already enabled, redirect to login
+  // Otherwise, allow access to setup page (even if setup was completed before)
+  // This allows users to enable auth later if they skipped it during initial setup
+  if (authEnabled) {
+    return <Navigate to="/login" replace />;
+  }
+
+  return <>{children}</>;
+}
+
 function App() {
   return (
     <ThemeProvider>
       <ToastProvider>
         <QueryClientProvider client={queryClient}>
-          <BrowserRouter>
-            <Routes>
-              {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
-              <Route path="/camera/:printerId" element={<CameraPage />} />
-
-              {/* Main app with WebSocket for real-time updates */}
-              <Route element={<WebSocketProvider><Layout /></WebSocketProvider>}>
-                <Route index element={<PrintersPage />} />
-                <Route path="archives" element={<ArchivesPage />} />
-                <Route path="queue" element={<QueuePage />} />
-                <Route path="stats" element={<StatsPage />} />
-                <Route path="profiles" element={<ProfilesPage />} />
-                <Route path="maintenance" element={<MaintenancePage />} />
-                <Route path="projects" element={<ProjectsPage />} />
-                <Route path="projects/:id" element={<ProjectDetailPage />} />
-                <Route path="files" element={<FileManagerPage />} />
-                <Route path="settings" element={<SettingsPage />} />
-                <Route path="system" element={<SystemInfoPage />} />
-                <Route path="external/:id" element={<ExternalLinkPage />} />
-              </Route>
-            </Routes>
-          </BrowserRouter>
+          <AuthProvider>
+            <BrowserRouter>
+              <Routes>
+                {/* Setup page - only accessible if auth not enabled */}
+                <Route path="/setup" element={<SetupRoute><SetupPage /></SetupRoute>} />
+
+                {/* Login page */}
+                <Route path="/login" element={<LoginPage />} />
+
+                {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
+                <Route path="/camera/:printerId" element={<CameraPage />} />
+
+                {/* Main app with WebSocket for real-time updates */}
+                <Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
+                  <Route index element={<PrintersPage />} />
+                  <Route path="archives" element={<ArchivesPage />} />
+                  <Route path="queue" element={<QueuePage />} />
+                  <Route path="stats" element={<StatsPage />} />
+                  <Route path="profiles" element={<ProfilesPage />} />
+                  <Route path="maintenance" element={<MaintenancePage />} />
+                  <Route path="projects" element={<ProjectsPage />} />
+                  <Route path="projects/:id" element={<ProjectDetailPage />} />
+                  <Route path="files" element={<FileManagerPage />} />
+                  <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
+                  <Route path="users" element={<UsersPage />} />
+                  <Route path="system" element={<SystemInfoPage />} />
+                  <Route path="external/:id" element={<ExternalLinkPage />} />
+                </Route>
+              </Routes>
+            </BrowserRouter>
+          </AuthProvider>
         </QueryClientProvider>
       </ToastProvider>
     </ThemeProvider>

+ 42 - 0
frontend/src/__tests__/components/ConfirmModal.test.tsx

@@ -122,4 +122,46 @@ describe('ConfirmModal', () => {
       expect(screen.getByText('Confirm Action')).toBeInTheDocument();
     });
   });
+
+  describe('loading state', () => {
+    it('shows loading text when isLoading is true', () => {
+      render(<ConfirmModal {...defaultProps} isLoading={true} loadingText="Deleting..." />);
+      expect(screen.getByText('Deleting...')).toBeInTheDocument();
+    });
+
+    it('shows default loading text when loadingText not provided', () => {
+      render(<ConfirmModal {...defaultProps} isLoading={true} />);
+      expect(screen.getByText('Processing...')).toBeInTheDocument();
+    });
+
+    it('disables buttons when loading', () => {
+      render(<ConfirmModal {...defaultProps} isLoading={true} />);
+      const buttons = screen.getAllByRole('button');
+      buttons.forEach(button => {
+        expect(button).toBeDisabled();
+      });
+    });
+
+    it('does not call onCancel when clicking backdrop while loading', async () => {
+      const user = userEvent.setup();
+      const onCancel = vi.fn();
+      const { container } = render(
+        <ConfirmModal {...defaultProps} onCancel={onCancel} isLoading={true} />
+      );
+
+      const backdrop = container.querySelector('.fixed');
+      if (backdrop) {
+        await user.click(backdrop);
+        expect(onCancel).not.toHaveBeenCalled();
+      }
+    });
+
+    it('does not call onCancel on Escape key while loading', () => {
+      const onCancel = vi.fn();
+      render(<ConfirmModal {...defaultProps} onCancel={onCancel} isLoading={true} />);
+
+      fireEvent.keyDown(window, { key: 'Escape' });
+      expect(onCancel).not.toHaveBeenCalled();
+    });
+  });
 });

+ 3 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -49,6 +49,9 @@ describe('Layout', () => {
       }),
       http.get('/api/v1/updates/check', () => {
         return HttpResponse.json({ update_available: false });
+      }),
+      http.get('/api/v1/auth/status', () => {
+        return HttpResponse.json({ auth_enabled: false, requires_setup: false });
       })
     );
   });

+ 157 - 0
frontend/src/__tests__/pages/LoginPage.test.tsx

@@ -0,0 +1,157 @@
+/**
+ * Tests for the LoginPage component.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { LoginPage } from '../../pages/LoginPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+describe('LoginPage', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/auth/status', () => {
+        return HttpResponse.json({ auth_enabled: true, requires_setup: false });
+      })
+    );
+  });
+
+  describe('rendering', () => {
+    it('renders the login form', async () => {
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
+      });
+
+      expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      expect(screen.getByLabelText(/Password/i)).toBeInTheDocument();
+      expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
+    });
+
+    it('renders the sign in description', async () => {
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/Sign in to your account/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('form validation', () => {
+    it('shows error when submitting empty form', async () => {
+      const user = userEvent.setup();
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      // The form has required fields, so HTML5 validation should prevent submission
+      // or the component shows a toast
+    });
+
+    it('allows entering username and password', async () => {
+      const user = userEvent.setup();
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByLabelText(/Username/i), 'testuser');
+      await user.type(screen.getByLabelText(/Password/i), 'testpassword');
+
+      expect(screen.getByLabelText(/Username/i)).toHaveValue('testuser');
+      expect(screen.getByLabelText(/Password/i)).toHaveValue('testpassword');
+    });
+  });
+
+  describe('login flow', () => {
+    it('submits login request with credentials', async () => {
+      const user = userEvent.setup();
+      let loginCalled = false;
+
+      server.use(
+        http.post('/api/v1/auth/login', async ({ request }) => {
+          loginCalled = true;
+          const body = await request.json() as { username: string; password: string };
+          if (body.username === 'validuser' && body.password === 'validpass') {
+            return HttpResponse.json({
+              access_token: 'test-token',
+              token_type: 'bearer',
+              user: {
+                id: 1,
+                username: 'validuser',
+                role: 'admin',
+                is_active: true,
+                created_at: new Date().toISOString(),
+              },
+            });
+          }
+          return HttpResponse.json(
+            { detail: 'Incorrect username or password' },
+            { status: 401 }
+          );
+        })
+      );
+
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByLabelText(/Username/i), 'validuser');
+      await user.type(screen.getByLabelText(/Password/i), 'validpass');
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      // Verify the login endpoint was called
+      await waitFor(() => {
+        expect(loginCalled).toBe(true);
+      });
+    });
+
+    it('shows loading state during login', async () => {
+      const user = userEvent.setup();
+
+      // Slow login endpoint
+      server.use(
+        http.post('/api/v1/auth/login', async () => {
+          await new Promise(resolve => setTimeout(resolve, 100));
+          return HttpResponse.json({
+            access_token: 'test-token',
+            token_type: 'bearer',
+            user: {
+              id: 1,
+              username: 'testuser',
+              role: 'admin',
+              is_active: true,
+              created_at: new Date().toISOString(),
+            },
+          });
+        })
+      );
+
+      render(<LoginPage />);
+
+      await waitFor(() => {
+        expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
+      });
+
+      await user.type(screen.getByLabelText(/Username/i), 'testuser');
+      await user.type(screen.getByLabelText(/Password/i), 'testpass');
+      await user.click(screen.getByRole('button', { name: /Sign in/i }));
+
+      // Check for loading state
+      await waitFor(() => {
+        expect(screen.getByRole('button')).toBeDisabled();
+      });
+    });
+  });
+});

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

@@ -60,6 +60,9 @@ describe('SettingsPage', () => {
       }),
       http.get('/api/v1/virtual-printer/status', () => {
         return HttpResponse.json({ running: false });
+      }),
+      http.get('/api/v1/auth/status', () => {
+        return HttpResponse.json({ auth_enabled: false, requires_setup: false });
       })
     );
   });

+ 4 - 1
frontend/src/__tests__/utils.tsx

@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { BrowserRouter } from 'react-router-dom';
 import { ThemeProvider } from '../contexts/ThemeContext';
 import { ToastProvider } from '../contexts/ToastContext';
+import { AuthProvider } from '../contexts/AuthContext';
 
 // Create a new QueryClient for each test
 function createTestQueryClient() {
@@ -36,7 +37,9 @@ function AllProviders({ children }: AllProvidersProps) {
     <QueryClientProvider client={queryClient}>
       <BrowserRouter>
         <ThemeProvider>
-          <ToastProvider>{children}</ToastProvider>
+          <AuthProvider>
+            <ToastProvider>{children}</ToastProvider>
+          </AuthProvider>
         </ThemeProvider>
       </BrowserRouter>
     </QueryClientProvider>

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

@@ -1,19 +1,47 @@
 const API_BASE = '/api/v1';
 
+// Auth token storage
+let authToken: string | null = localStorage.getItem('auth_token');
+
+export function setAuthToken(token: string | null) {
+  authToken = token;
+  if (token) {
+    localStorage.setItem('auth_token', token);
+  } else {
+    localStorage.removeItem('auth_token');
+  }
+}
+
+export function getAuthToken(): string | null {
+  return authToken;
+}
+
 async function request<T>(
   endpoint: string,
   options: RequestInit = {}
 ): Promise<T> {
+  const headers: Record<string, string> = {
+    'Content-Type': 'application/json',
+    ...options.headers as Record<string, string>,
+  };
+
+  // Add auth token if available
+  if (authToken) {
+    headers['Authorization'] = `Bearer ${authToken}`;
+  }
+
   const response = await fetch(`${API_BASE}${endpoint}`, {
     ...options,
     cache: 'no-store', // Prevent browser caching of API responses
-    headers: {
-      'Content-Type': 'application/json',
-      ...options.headers,
-    },
+    headers,
   });
 
   if (!response.ok) {
+    // Handle 401 Unauthorized - clear token and redirect to login
+    if (response.status === 401) {
+      setAuthToken(null);
+      // Don't throw here - let the auth context handle redirect
+    }
     const error = await response.json().catch(() => ({}));
     const detail = error.detail;
     const message = typeof detail === 'string'
@@ -589,6 +617,8 @@ export interface AppSettings {
   mqtt_password: string;
   mqtt_topic_prefix: string;
   mqtt_use_tls: boolean;
+  // External URL for notifications
+  external_url: string;
   // Home Assistant integration
   ha_enabled: boolean;
   ha_url: string;
@@ -710,6 +740,10 @@ export interface SmartPlug {
   plug_type: 'tasmota' | 'homeassistant';
   ip_address: string | null;  // Required for Tasmota
   ha_entity_id: string | null;  // Required for Home Assistant (e.g., "switch.printer_plug")
+  // Home Assistant energy sensor entities (optional)
+  ha_power_entity: string | null;
+  ha_energy_today_entity: string | null;
+  ha_energy_total_entity: string | null;
   printer_id: number | null;
   enabled: boolean;
   auto_on: boolean;
@@ -743,6 +777,10 @@ export interface SmartPlugCreate {
   plug_type?: 'tasmota' | 'homeassistant';
   ip_address?: string | null;  // Required for Tasmota
   ha_entity_id?: string | null;  // Required for Home Assistant
+  // Home Assistant energy sensor entities (optional)
+  ha_power_entity?: string | null;
+  ha_energy_today_entity?: string | null;
+  ha_energy_total_entity?: string | null;
   printer_id?: number | null;
   enabled?: boolean;
   auto_on?: boolean;
@@ -769,6 +807,10 @@ export interface SmartPlugUpdate {
   plug_type?: 'tasmota' | 'homeassistant';
   ip_address?: string | null;
   ha_entity_id?: string | null;
+  // Home Assistant energy sensor entities (optional)
+  ha_power_entity?: string | null;
+  ha_energy_today_entity?: string | null;
+  ha_energy_total_entity?: string | null;
   printer_id?: number | null;
   enabled?: boolean;
   auto_on?: boolean;
@@ -798,6 +840,14 @@ export interface HAEntity {
   domain: string;  // "switch", "light", "input_boolean"
 }
 
+// Home Assistant sensor entity for energy monitoring
+export interface HASensorEntity {
+  entity_id: string;
+  friendly_name: string;
+  state: string | null;
+  unit_of_measurement: string | null;  // "W", "kW", "kWh", "Wh"
+}
+
 export interface HATestConnectionResult {
   success: boolean;
   message: string | null;
@@ -1375,8 +1425,97 @@ export interface ExternalLinkUpdate {
   icon?: string;
 }
 
+// Auth types
+export interface LoginRequest {
+  username: string;
+  password: string;
+}
+
+export interface LoginResponse {
+  access_token: string;
+  token_type: string;
+  user: UserResponse;
+}
+
+export interface UserResponse {
+  id: number;
+  username: string;
+  role: string;
+  is_active: boolean;
+  created_at: string;
+}
+
+export interface UserCreate {
+  username: string;
+  password: string;
+  role: string;
+}
+
+export interface UserUpdate {
+  username?: string;
+  password?: string;
+  role?: string;
+  is_active?: boolean;
+}
+
+export interface SetupRequest {
+  auth_enabled: boolean;
+  admin_username?: string;
+  admin_password?: string;
+}
+
+export interface SetupResponse {
+  auth_enabled: boolean;
+  admin_created?: boolean;
+}
+
+export interface AuthStatus {
+  auth_enabled: boolean;
+  requires_setup: boolean;
+}
+
 // API functions
 export const api = {
+  // Authentication
+  getAuthStatus: () => request<AuthStatus>('/auth/status'),
+  setupAuth: (data: SetupRequest) =>
+    request<SetupResponse>('/auth/setup', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  login: (data: LoginRequest) =>
+    request<LoginResponse>('/auth/login', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  logout: () =>
+    request<{ message: string }>('/auth/logout', {
+      method: 'POST',
+    }),
+  getCurrentUser: () => request<UserResponse>('/auth/me'),
+  disableAuth: () =>
+    request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
+      method: 'POST',
+    }),
+
+  // Users (admin only)
+  getUsers: () => request<UserResponse[]>('/users/'),
+  getUser: (id: number) => request<UserResponse>(`/users/${id}`),
+  createUser: (data: UserCreate) =>
+    request<UserResponse>('/users/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateUser: (id: number, data: UserUpdate) =>
+    request<UserResponse>(`/users/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteUser: (id: number) =>
+    request<void>(`/users/${id}`, {
+      method: 'DELETE',
+    }),
+
   // Printers
   getPrinters: () => request<Printer[]>('/printers/'),
   getPrinter: (id: number) => request<Printer>(`/printers/${id}`),
@@ -2072,8 +2211,12 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ url, token }),
     }),
-  getHAEntities: () =>
-    request<HAEntity[]>('/smart-plugs/ha/entities'),
+  getHAEntities: (search?: string) => {
+    const params = search ? `?search=${encodeURIComponent(search)}` : '';
+    return request<HAEntity[]>(`/smart-plugs/ha/entities${params}`);
+  },
+  getHASensorEntities: () =>
+    request<HASensorEntity[]>('/smart-plugs/ha/sensors'),
 
   // Print Queue
   getQueue: (printerId?: number, status?: string) => {
@@ -2577,6 +2720,26 @@ export const api = {
     }
     return response.json();
   },
+  extractZipFile: async (
+    file: File,
+    folderId?: number | null,
+    preserveStructure: boolean = true
+  ): Promise<ZipExtractResponse> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const params = new URLSearchParams();
+    if (folderId) params.set('folder_id', String(folderId));
+    params.set('preserve_structure', String(preserveStructure));
+    const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
+      method: 'POST',
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
   updateLibraryFile: (id: number, data: LibraryFileUpdate) =>
     request<LibraryFile>(`/library/files/${id}`, {
       method: 'PUT',
@@ -2864,6 +3027,24 @@ export interface LibraryStats {
   disk_used_bytes: number;
 }
 
+export interface ZipExtractResult {
+  filename: string;
+  file_id: number;
+  folder_id: number | null;
+}
+
+export interface ZipExtractError {
+  filename: string;
+  error: string;
+}
+
+export interface ZipExtractResponse {
+  extracted: number;
+  folders_created: number;
+  files: ZipExtractResult[];
+  errors: ZipExtractError[];
+}
+
 // Library Queue types
 export interface AddToQueueResult {
   file_id: number;

+ 10 - 4
frontend/src/components/AddNotificationModal.tsx

@@ -206,9 +206,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       case 'webhook':
         return [
           { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true },
+          { key: 'payload_format', label: 'Payload Format', type: 'select', required: false, options: [
+            { value: 'generic', label: 'Generic JSON' },
+            { value: 'slack', label: 'Slack / Mattermost' },
+          ]},
           { key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false },
-          { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false },
-          { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false },
+          { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },
+          { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false, showIf: (cfg: Record<string, string>) => cfg.payload_format !== 'slack' },
         ];
       default:
         return [];
@@ -290,12 +294,14 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           {/* Provider-specific configuration */}
           <div className="space-y-3">
             <p className="text-sm text-bambu-gray">Configuration</p>
-            {configFields.map((field) => (
+            {configFields
+              .filter((field) => !('showIf' in field) || (field as { showIf?: (cfg: Record<string, string>) => boolean }).showIf?.(config) !== false)
+              .map((field) => (
               <div key={field.key}>
                 <label className="block text-sm text-bambu-gray mb-1">
                   {field.label} {field.required && '*'}
                 </label>
-                {field.type === 'select' && field.options ? (
+                {field.type === 'select' && 'options' in field && field.options ? (
                   <select
                     value={config[field.key] || field.options[0]?.value || ''}
                     onChange={(e) => {

+ 413 - 38
frontend/src/components/AddSmartPlugModal.tsx

@@ -24,6 +24,28 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [password, setPassword] = useState(plug?.password || '');
   // Home Assistant fields
   const [haEntityId, setHaEntityId] = useState(plug?.ha_entity_id || '');
+  // HA energy sensor entities (optional)
+  const [haPowerEntity, setHaPowerEntity] = useState(plug?.ha_power_entity || '');
+  const [haEnergyTodayEntity, setHaEnergyTodayEntity] = useState(plug?.ha_energy_today_entity || '');
+  const [haEnergyTotalEntity, setHaEnergyTotalEntity] = useState(plug?.ha_energy_total_entity || '');
+  // HA entity search
+  const [haEntitySearch, setHaEntitySearch] = useState('');
+  const [debouncedSearch, setDebouncedSearch] = useState('');
+  const [isEntityDropdownOpen, setIsEntityDropdownOpen] = useState(false);
+  const entityDropdownRef = useRef<HTMLDivElement>(null);
+
+  // Energy sensor search states
+  const [powerSensorSearch, setPowerSensorSearch] = useState('');
+  const [isPowerDropdownOpen, setIsPowerDropdownOpen] = useState(false);
+  const powerDropdownRef = useRef<HTMLDivElement>(null);
+
+  const [energyTodaySearch, setEnergyTodaySearch] = useState('');
+  const [isEnergyTodayDropdownOpen, setIsEnergyTodayDropdownOpen] = useState(false);
+  const energyTodayDropdownRef = useRef<HTMLDivElement>(null);
+
+  const [energyTotalSearch, setEnergyTotalSearch] = useState('');
+  const [isEnergyTotalDropdownOpen, setIsEnergyTotalDropdownOpen] = useState(false);
+  const energyTotalDropdownRef = useRef<HTMLDivElement>(null);
 
   const [printerId, setPrinterId] = useState<number | null>(plug?.printer_id || null);
   const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null);
@@ -69,10 +91,47 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   // Check if HA is properly configured
   const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
 
+  // Debounce search input
+  useEffect(() => {
+    const timer = setTimeout(() => {
+      setDebouncedSearch(haEntitySearch);
+    }, 300);
+    return () => clearTimeout(timer);
+  }, [haEntitySearch]);
+
+  // Close dropdowns when clicking outside
+  useEffect(() => {
+    const handleClickOutside = (e: MouseEvent) => {
+      if (entityDropdownRef.current && !entityDropdownRef.current.contains(e.target as Node)) {
+        setIsEntityDropdownOpen(false);
+      }
+      if (powerDropdownRef.current && !powerDropdownRef.current.contains(e.target as Node)) {
+        setIsPowerDropdownOpen(false);
+      }
+      if (energyTodayDropdownRef.current && !energyTodayDropdownRef.current.contains(e.target as Node)) {
+        setIsEnergyTodayDropdownOpen(false);
+      }
+      if (energyTotalDropdownRef.current && !energyTotalDropdownRef.current.contains(e.target as Node)) {
+        setIsEnergyTotalDropdownOpen(false);
+      }
+    };
+    document.addEventListener('mousedown', handleClickOutside);
+    return () => document.removeEventListener('mousedown', handleClickOutside);
+  }, []);
+
   // Fetch Home Assistant entities when in HA mode AND HA is configured
-  const { data: haEntities, isLoading: haEntitiesLoading } = useQuery({
-    queryKey: ['ha-entities'],
-    queryFn: api.getHAEntities,
+  const { data: haEntities, isLoading: haEntitiesLoading, error: haEntitiesError } = useQuery({
+    queryKey: ['ha-entities', debouncedSearch],
+    queryFn: () => api.getHAEntities(debouncedSearch || undefined),
+    enabled: plugType === 'homeassistant' && haConfigured,
+    retry: false,
+    staleTime: 0,
+  });
+
+  // Fetch Home Assistant sensor entities for energy monitoring
+  const { data: haSensorEntities } = useQuery({
+    queryKey: ['ha-sensor-entities'],
+    queryFn: api.getHASensorEntities,
     enabled: plugType === 'homeassistant' && haConfigured,
     retry: false,
     staleTime: 0,
@@ -225,6 +284,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       plug_type: plugType,
       ip_address: plugType === 'tasmota' ? ipAddress.trim() : null,
       ha_entity_id: plugType === 'homeassistant' ? haEntityId : null,
+      // HA energy sensor entities (optional)
+      ha_power_entity: plugType === 'homeassistant' ? (haPowerEntity || null) : null,
+      ha_energy_today_entity: plugType === 'homeassistant' ? (haEnergyTodayEntity || null) : null,
+      ha_energy_total_entity: plugType === 'homeassistant' ? (haEnergyTotalEntity || null) : null,
       username: plugType === 'tasmota' ? (username.trim() || null) : null,
       password: plugType === 'tasmota' ? (password.trim() || null) : null,
       printer_id: printerId,
@@ -422,59 +485,371 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
                     </div>
                   )}
 
-                  {haEntities && haEntities.length === 0 && (
-                    <div className="p-3 bg-yellow-500/20 border border-yellow-500/50 rounded-lg text-sm text-yellow-400">
-                      No switch/light entities found in Home Assistant
+                  {haEntitiesError && (
+                    <div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">
+                      Failed to load entities: {(haEntitiesError as Error).message}
                     </div>
                   )}
 
-                  {haEntities && haEntities.length > 0 && (() => {
+                  {/* Searchable Entity Dropdown */}
+                  {(() => {
                     // Filter out entities already configured (except current plug when editing)
                     const configuredEntityIds = existingPlugs
                       ?.filter(p => p.ha_entity_id && p.id !== plug?.id)
                       .map(p => p.ha_entity_id) || [];
-                    const availableEntities = haEntities.filter(e => !configuredEntityIds.includes(e.entity_id));
+                    const availableEntities = (haEntities || []).filter(e => !configuredEntityIds.includes(e.entity_id));
+                    const selectedEntity = haEntities?.find(e => e.entity_id === haEntityId);
 
                     return (
-                      <div>
+                      <div ref={entityDropdownRef} className="relative">
                         <label className="block text-sm text-bambu-gray mb-1">Select Entity *</label>
-                        <select
-                          value={haEntityId}
-                          onChange={(e) => {
-                            setHaEntityId(e.target.value);
-                            // Auto-fill name from entity friendly name
-                            const entity = haEntities?.find(ent => ent.entity_id === e.target.value);
-                            if (entity && !name) {
-                              setName(entity.friendly_name);
-                            }
-                          }}
-                          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="">Choose an entity...</option>
-                          {availableEntities.map((entity) => (
-                            <option key={entity.entity_id} value={entity.entity_id}>
-                              {entity.friendly_name} ({entity.entity_id}) - {entity.state}
-                            </option>
-                          ))}
-                        </select>
-                        {configuredEntityIds.length > 0 && (
-                          <p className="text-xs text-bambu-gray mt-1">
-                            {configuredEntityIds.length} entity(s) already configured
-                          </p>
+                        <div className="relative">
+                          <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                          <input
+                            type="text"
+                            value={isEntityDropdownOpen ? haEntitySearch : (selectedEntity ? `${selectedEntity.friendly_name} (${selectedEntity.entity_id})` : '')}
+                            onChange={(e) => {
+                              setHaEntitySearch(e.target.value);
+                              if (!isEntityDropdownOpen) setIsEntityDropdownOpen(true);
+                            }}
+                            onFocus={() => {
+                              setIsEntityDropdownOpen(true);
+                              setHaEntitySearch('');
+                            }}
+                            placeholder="Search entities..."
+                            className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                          />
+                          {haEntityId && !isEntityDropdownOpen && (
+                            <button
+                              type="button"
+                              onClick={() => {
+                                setHaEntityId('');
+                                setHaEntitySearch('');
+                              }}
+                              className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
+                            >
+                              <X className="w-4 h-4 text-bambu-gray hover:text-white" />
+                            </button>
+                          )}
+                          {haEntitiesLoading && (
+                            <Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray animate-spin" />
+                          )}
+                        </div>
+
+                        {/* Dropdown */}
+                        {isEntityDropdownOpen && (
+                          <div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-60 overflow-y-auto">
+                            {haEntitiesLoading && (
+                              <div className="px-3 py-2 text-sm text-bambu-gray flex items-center gap-2">
+                                <Loader2 className="w-4 h-4 animate-spin" />
+                                Loading...
+                              </div>
+                            )}
+                            {!haEntitiesLoading && availableEntities.length === 0 && (
+                              <div className="px-3 py-2 text-sm text-bambu-gray">
+                                {debouncedSearch
+                                  ? `No entities found matching "${debouncedSearch}"`
+                                  : 'No entities available'}
+                              </div>
+                            )}
+                            {!haEntitiesLoading && availableEntities.map((entity) => (
+                              <button
+                                key={entity.entity_id}
+                                type="button"
+                                onClick={() => {
+                                  setHaEntityId(entity.entity_id);
+                                  setIsEntityDropdownOpen(false);
+                                  setHaEntitySearch('');
+                                  // Auto-fill name
+                                  if (!name) {
+                                    setName(entity.friendly_name);
+                                  }
+                                }}
+                                className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary transition-colors ${
+                                  entity.entity_id === haEntityId ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
+                                }`}
+                              >
+                                <div className="font-medium">{entity.friendly_name}</div>
+                                <div className="text-xs text-bambu-gray flex items-center justify-between">
+                                  <span>{entity.entity_id}</span>
+                                  <span className={entity.state === 'on' ? 'text-bambu-green' : ''}>{entity.state}</span>
+                                </div>
+                              </button>
+                            ))}
+                          </div>
                         )}
+
+                        <p className="text-xs text-bambu-gray mt-1">
+                          {debouncedSearch
+                            ? `Searching all entities (${availableEntities.length} found)`
+                            : `Showing switch, light, input_boolean (${availableEntities.length} available)`}
+                        </p>
                       </div>
                     );
                   })()}
 
-                  {haEntityId && haEntities && (
-                    <div className="p-3 bg-bambu-green/20 border border-bambu-green/50 rounded-lg text-sm text-bambu-green flex items-center gap-2">
-                      <CheckCircle className="w-5 h-5" />
+
+                  {/* Energy Monitoring Section (Optional) */}
+                  {haEntityId && haSensorEntities && haSensorEntities.length > 0 && (
+                    <div className="border-t border-bambu-dark-tertiary pt-4 mt-4 space-y-3">
                       <div>
-                        <p className="font-medium">Entity selected</p>
-                        <p className="text-xs opacity-80">
-                          {haEntities.find(e => e.entity_id === haEntityId)?.friendly_name} - {haEntities.find(e => e.entity_id === haEntityId)?.state}
+                        <p className="text-white font-medium mb-1">Energy Monitoring (Optional)</p>
+                        <p className="text-xs text-bambu-gray mb-3">
+                          Search and select sensors that provide power/energy data.
                         </p>
                       </div>
+
+                      {/* Power Sensor (W) */}
+                      {(() => {
+                        const powerSensors = haSensorEntities.filter(s =>
+                          s.unit_of_measurement === 'W' || s.unit_of_measurement === 'kW' || s.unit_of_measurement === 'mW'
+                        );
+                        const filteredPowerSensors = powerSensorSearch
+                          ? powerSensors.filter(s =>
+                              s.entity_id.toLowerCase().includes(powerSensorSearch.toLowerCase()) ||
+                              s.friendly_name.toLowerCase().includes(powerSensorSearch.toLowerCase())
+                            )
+                          : powerSensors;
+                        const selectedPowerSensor = haSensorEntities.find(s => s.entity_id === haPowerEntity);
+
+                        return (
+                          <div ref={powerDropdownRef} className="relative">
+                            <label className="block text-sm text-bambu-gray mb-1">Power Sensor (W)</label>
+                            <div className="relative">
+                              <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                              <input
+                                type="text"
+                                value={isPowerDropdownOpen ? powerSensorSearch : (selectedPowerSensor ? `${selectedPowerSensor.friendly_name} (${selectedPowerSensor.state} ${selectedPowerSensor.unit_of_measurement})` : '')}
+                                onChange={(e) => {
+                                  setPowerSensorSearch(e.target.value);
+                                  if (!isPowerDropdownOpen) setIsPowerDropdownOpen(true);
+                                }}
+                                onFocus={() => {
+                                  setIsPowerDropdownOpen(true);
+                                  setPowerSensorSearch('');
+                                }}
+                                placeholder="Search power sensors..."
+                                className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                              />
+                              {haPowerEntity && !isPowerDropdownOpen && (
+                                <button
+                                  type="button"
+                                  onClick={() => {
+                                    setHaPowerEntity('');
+                                    setPowerSensorSearch('');
+                                  }}
+                                  className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
+                                >
+                                  <X className="w-4 h-4 text-bambu-gray hover:text-white" />
+                                </button>
+                              )}
+                            </div>
+                            {isPowerDropdownOpen && (
+                              <div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
+                                <button
+                                  type="button"
+                                  onClick={() => {
+                                    setHaPowerEntity('');
+                                    setIsPowerDropdownOpen(false);
+                                    setPowerSensorSearch('');
+                                  }}
+                                  className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
+                                >
+                                  None
+                                </button>
+                                {filteredPowerSensors.map((sensor) => (
+                                  <button
+                                    key={sensor.entity_id}
+                                    type="button"
+                                    onClick={() => {
+                                      setHaPowerEntity(sensor.entity_id);
+                                      setIsPowerDropdownOpen(false);
+                                      setPowerSensorSearch('');
+                                    }}
+                                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+                                      sensor.entity_id === haPowerEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
+                                    }`}
+                                  >
+                                    <div className="font-medium">{sensor.friendly_name}</div>
+                                    <div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
+                                  </button>
+                                ))}
+                                {filteredPowerSensors.length === 0 && (
+                                  <div className="px-3 py-2 text-sm text-bambu-gray">No matching sensors</div>
+                                )}
+                              </div>
+                            )}
+                          </div>
+                        );
+                      })()}
+
+                      {/* Energy Today (kWh) */}
+                      {(() => {
+                        const energySensors = haSensorEntities.filter(s =>
+                          s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'
+                        );
+                        const filteredEnergySensors = energyTodaySearch
+                          ? energySensors.filter(s =>
+                              s.entity_id.toLowerCase().includes(energyTodaySearch.toLowerCase()) ||
+                              s.friendly_name.toLowerCase().includes(energyTodaySearch.toLowerCase())
+                            )
+                          : energySensors;
+                        const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTodayEntity);
+
+                        return (
+                          <div ref={energyTodayDropdownRef} className="relative">
+                            <label className="block text-sm text-bambu-gray mb-1">Energy Today (kWh)</label>
+                            <div className="relative">
+                              <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                              <input
+                                type="text"
+                                value={isEnergyTodayDropdownOpen ? energyTodaySearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}
+                                onChange={(e) => {
+                                  setEnergyTodaySearch(e.target.value);
+                                  if (!isEnergyTodayDropdownOpen) setIsEnergyTodayDropdownOpen(true);
+                                }}
+                                onFocus={() => {
+                                  setIsEnergyTodayDropdownOpen(true);
+                                  setEnergyTodaySearch('');
+                                }}
+                                placeholder="Search energy sensors..."
+                                className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                              />
+                              {haEnergyTodayEntity && !isEnergyTodayDropdownOpen && (
+                                <button
+                                  type="button"
+                                  onClick={() => {
+                                    setHaEnergyTodayEntity('');
+                                    setEnergyTodaySearch('');
+                                  }}
+                                  className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
+                                >
+                                  <X className="w-4 h-4 text-bambu-gray hover:text-white" />
+                                </button>
+                              )}
+                            </div>
+                            {isEnergyTodayDropdownOpen && (
+                              <div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
+                                <button
+                                  type="button"
+                                  onClick={() => {
+                                    setHaEnergyTodayEntity('');
+                                    setIsEnergyTodayDropdownOpen(false);
+                                    setEnergyTodaySearch('');
+                                  }}
+                                  className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
+                                >
+                                  None
+                                </button>
+                                {filteredEnergySensors.map((sensor) => (
+                                  <button
+                                    key={sensor.entity_id}
+                                    type="button"
+                                    onClick={() => {
+                                      setHaEnergyTodayEntity(sensor.entity_id);
+                                      setIsEnergyTodayDropdownOpen(false);
+                                      setEnergyTodaySearch('');
+                                    }}
+                                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+                                      sensor.entity_id === haEnergyTodayEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
+                                    }`}
+                                  >
+                                    <div className="font-medium">{sensor.friendly_name}</div>
+                                    <div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
+                                  </button>
+                                ))}
+                                {filteredEnergySensors.length === 0 && (
+                                  <div className="px-3 py-2 text-sm text-bambu-gray">No matching sensors</div>
+                                )}
+                              </div>
+                            )}
+                          </div>
+                        );
+                      })()}
+
+                      {/* Total Energy (kWh) */}
+                      {(() => {
+                        const energySensors = haSensorEntities.filter(s =>
+                          s.unit_of_measurement === 'kWh' || s.unit_of_measurement === 'Wh' || s.unit_of_measurement === 'MWh'
+                        );
+                        const filteredEnergySensors = energyTotalSearch
+                          ? energySensors.filter(s =>
+                              s.entity_id.toLowerCase().includes(energyTotalSearch.toLowerCase()) ||
+                              s.friendly_name.toLowerCase().includes(energyTotalSearch.toLowerCase())
+                            )
+                          : energySensors;
+                        const selectedSensor = haSensorEntities.find(s => s.entity_id === haEnergyTotalEntity);
+
+                        return (
+                          <div ref={energyTotalDropdownRef} className="relative">
+                            <label className="block text-sm text-bambu-gray mb-1">Total Energy (kWh)</label>
+                            <div className="relative">
+                              <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+                              <input
+                                type="text"
+                                value={isEnergyTotalDropdownOpen ? energyTotalSearch : (selectedSensor ? `${selectedSensor.friendly_name} (${selectedSensor.state} ${selectedSensor.unit_of_measurement})` : '')}
+                                onChange={(e) => {
+                                  setEnergyTotalSearch(e.target.value);
+                                  if (!isEnergyTotalDropdownOpen) setIsEnergyTotalDropdownOpen(true);
+                                }}
+                                onFocus={() => {
+                                  setIsEnergyTotalDropdownOpen(true);
+                                  setEnergyTotalSearch('');
+                                }}
+                                placeholder="Search energy sensors..."
+                                className="w-full pl-9 pr-8 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                              />
+                              {haEnergyTotalEntity && !isEnergyTotalDropdownOpen && (
+                                <button
+                                  type="button"
+                                  onClick={() => {
+                                    setHaEnergyTotalEntity('');
+                                    setEnergyTotalSearch('');
+                                  }}
+                                  className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-bambu-dark-tertiary rounded"
+                                >
+                                  <X className="w-4 h-4 text-bambu-gray hover:text-white" />
+                                </button>
+                              )}
+                            </div>
+                            {isEnergyTotalDropdownOpen && (
+                              <div className="absolute z-50 w-full mt-1 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg shadow-lg max-h-48 overflow-y-auto">
+                                <button
+                                  type="button"
+                                  onClick={() => {
+                                    setHaEnergyTotalEntity('');
+                                    setIsEnergyTotalDropdownOpen(false);
+                                    setEnergyTotalSearch('');
+                                  }}
+                                  className="w-full px-3 py-2 text-left text-sm text-bambu-gray hover:bg-bambu-dark-tertiary"
+                                >
+                                  None
+                                </button>
+                                {filteredEnergySensors.map((sensor) => (
+                                  <button
+                                    key={sensor.entity_id}
+                                    type="button"
+                                    onClick={() => {
+                                      setHaEnergyTotalEntity(sensor.entity_id);
+                                      setIsEnergyTotalDropdownOpen(false);
+                                      setEnergyTotalSearch('');
+                                    }}
+                                    className={`w-full px-3 py-2 text-left text-sm hover:bg-bambu-dark-tertiary ${
+                                      sensor.entity_id === haEnergyTotalEntity ? 'bg-bambu-green/20 text-bambu-green' : 'text-white'
+                                    }`}
+                                  >
+                                    <div className="font-medium">{sensor.friendly_name}</div>
+                                    <div className="text-xs text-bambu-gray">{sensor.entity_id} • {sensor.state} {sensor.unit_of_measurement}</div>
+                                  </button>
+                                ))}
+                                {filteredEnergySensors.length === 0 && (
+                                  <div className="px-3 py-2 text-sm text-bambu-gray">No matching sensors</div>
+                                )}
+                              </div>
+                            )}
+                          </div>
+                        );
+                      })()}
                     </div>
                   )}
                 </>

+ 19 - 7
frontend/src/components/ConfirmModal.tsx

@@ -1,5 +1,5 @@
 import { useEffect } from 'react';
-import { AlertTriangle } from 'lucide-react';
+import { AlertTriangle, Loader2 } from 'lucide-react';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 
@@ -9,6 +9,8 @@ interface ConfirmModalProps {
   confirmText?: string;
   cancelText?: string;
   variant?: 'danger' | 'warning' | 'default';
+  isLoading?: boolean;
+  loadingText?: string;
   onConfirm: () => void;
   onCancel: () => void;
 }
@@ -19,17 +21,19 @@ export function ConfirmModal({
   confirmText = 'Confirm',
   cancelText = 'Cancel',
   variant = 'default',
+  isLoading = false,
+  loadingText,
   onConfirm,
   onCancel,
 }: ConfirmModalProps) {
-  // Close on Escape key
+  // Close on Escape key (but not while loading)
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape') onCancel();
+      if (e.key === 'Escape' && !isLoading) onCancel();
     };
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [onCancel]);
+  }, [onCancel, isLoading]);
 
   const variantStyles = {
     danger: {
@@ -51,7 +55,7 @@ export function ConfirmModal({
   return (
     <div
       className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
-      onClick={onCancel}
+      onClick={isLoading ? undefined : onCancel}
     >
       <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
         <CardContent className="p-6">
@@ -65,14 +69,22 @@ export function ConfirmModal({
             </div>
           </div>
           <div className="flex gap-3 mt-6">
-            <Button variant="secondary" onClick={onCancel} className="flex-1">
+            <Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
               {cancelText}
             </Button>
             <Button
               onClick={onConfirm}
               className={`flex-1 ${styles.button}`}
+              disabled={isLoading}
             >
-              {confirmText}
+              {isLoading ? (
+                <>
+                  <Loader2 className="w-4 h-4 mr-2 animate-spin" />
+                  {loadingText || 'Processing...'}
+                </>
+              ) : (
+                confirmText
+              )}
             </Button>
           </div>
         </CardContent>

+ 149 - 17
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useQuery } from '@tanstack/react-query';
-import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff } from 'lucide-react';
+import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
 import { api } from '../api/client';
 
 interface EmbeddedCameraViewerProps {
@@ -65,6 +65,11 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   const [isResizing, setIsResizing] = useState(false);
   const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
   const [isMinimized, setIsMinimized] = useState(false);
+  const [isFullscreen, setIsFullscreen] = useState(false);
+  const [zoomLevel, setZoomLevel] = useState(1);
+  const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
+  const [isPanning, setIsPanning] = useState(false);
+  const [panStart, setPanStart] = useState({ x: 0, y: 0 });
 
   // Stream state
   const [streamError, setStreamError] = useState(false);
@@ -202,6 +207,81 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
     };
   }, [streamLoading, streamError, isReconnecting, isMinimized, printerId, attemptReconnect]);
 
+  // Fullscreen change listener
+  useEffect(() => {
+    const handleFullscreenChange = () => {
+      const nowFullscreen = !!document.fullscreenElement;
+      setIsFullscreen(nowFullscreen);
+      // Reset zoom and pan when exiting fullscreen
+      if (!nowFullscreen) {
+        setZoomLevel(1);
+        setPanOffset({ x: 0, y: 0 });
+      }
+    };
+    document.addEventListener('fullscreenchange', handleFullscreenChange);
+    return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
+  }, []);
+
+  const toggleFullscreen = () => {
+    if (!containerRef.current) return;
+    if (document.fullscreenElement) {
+      document.exitFullscreen();
+    } else {
+      containerRef.current.requestFullscreen();
+    }
+  };
+
+  const handleZoomIn = () => {
+    setZoomLevel(prev => Math.min(prev + 0.5, 4));
+  };
+
+  const handleZoomOut = () => {
+    setZoomLevel(prev => {
+      const newZoom = Math.max(prev - 0.5, 1);
+      if (newZoom === 1) setPanOffset({ x: 0, y: 0 });
+      return newZoom;
+    });
+  };
+
+  const handleWheel = (e: React.WheelEvent) => {
+    e.preventDefault();
+    if (e.deltaY < 0) {
+      handleZoomIn();
+    } else {
+      handleZoomOut();
+    }
+  };
+
+  const handleImageMouseDown = (e: React.MouseEvent) => {
+    if (zoomLevel > 1) {
+      e.preventDefault();
+      setIsPanning(true);
+      setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y });
+    }
+  };
+
+  const handleImageMouseMove = (e: React.MouseEvent) => {
+    if (isPanning && zoomLevel > 1) {
+      const newX = e.clientX - panStart.x;
+      const newY = e.clientY - panStart.y;
+      // Limit panning based on zoom level
+      const maxPan = (zoomLevel - 1) * 150;
+      setPanOffset({
+        x: Math.max(-maxPan, Math.min(maxPan, newX)),
+        y: Math.max(-maxPan, Math.min(maxPan, newY)),
+      });
+    }
+  };
+
+  const handleImageMouseUp = () => {
+    setIsPanning(false);
+  };
+
+  const resetZoom = () => {
+    setZoomLevel(1);
+    setPanOffset({ x: 0, y: 0 });
+  };
+
   const handleStreamError = () => {
     setStreamLoading(false);
     if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
@@ -288,8 +368,8 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   return (
     <div
       ref={containerRef}
-      className="fixed z-50 bg-bambu-dark-secondary rounded-lg shadow-2xl border border-bambu-dark-tertiary overflow-hidden"
-      style={{
+      className={`${isFullscreen ? 'fixed inset-0 z-[100]' : 'fixed z-50 rounded-lg shadow-2xl border border-bambu-dark-tertiary'} bg-bambu-dark-secondary overflow-hidden`}
+      style={isFullscreen ? undefined : {
         left: state.x,
         top: state.y,
         width: isMinimized ? 200 : state.width,
@@ -315,6 +395,17 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           >
             <RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />
           </button>
+          <button
+            onClick={toggleFullscreen}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded"
+            title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
+          >
+            {isFullscreen ? (
+              <Minimize className="w-3.5 h-3.5 text-bambu-gray" />
+            ) : (
+              <Fullscreen className="w-3.5 h-3.5 text-bambu-gray" />
+            )}
+          </button>
           <button
             onClick={() => setIsMinimized(!isMinimized)}
             className="p-1 hover:bg-bambu-dark-tertiary rounded"
@@ -338,7 +429,13 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
 
       {/* Video area */}
       {!isMinimized && (
-        <div className="relative w-full h-[calc(100%-40px)] bg-black flex items-center justify-center">
+        <div
+          className={`relative w-full bg-black flex items-center justify-center overflow-hidden ${isFullscreen ? 'h-[calc(100%-40px)]' : 'h-[calc(100%-40px)]'}`}
+          onWheel={handleWheel}
+          onMouseMove={handleImageMouseMove}
+          onMouseUp={handleImageMouseUp}
+          onMouseLeave={handleImageMouseUp}
+        >
           {streamLoading && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
               <RefreshCw className="w-6 h-6 text-bambu-gray animate-spin" />
@@ -373,25 +470,60 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
             key={imageKey}
             src={streamUrl}
             alt="Camera stream"
-            className="max-w-full max-h-full object-contain"
+            className="max-w-full max-h-full object-contain select-none"
+            style={{
+              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
+              cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
+            }}
             onError={handleStreamError}
             onLoad={handleStreamLoad}
+            onMouseDown={handleImageMouseDown}
+            draggable={false}
           />
 
-          {/* Resize handle */}
-          <div
-            className="absolute bottom-0 right-0 w-6 h-6 cursor-se-resize no-drag hover:bg-white/10 rounded-tl transition-colors"
-            onMouseDown={handleResizeMouseDown}
-            title="Drag to resize"
-          >
-            <svg
-              className="w-6 h-6 text-bambu-gray/70 hover:text-bambu-gray"
-              viewBox="0 0 24 24"
-              fill="currentColor"
+          {/* Zoom controls */}
+          <div className="absolute bottom-2 left-2 flex items-center gap-1 bg-black/60 rounded px-1.5 py-1 no-drag">
+            <button
+              onClick={handleZoomOut}
+              disabled={zoomLevel <= 1}
+              className="p-1 hover:bg-white/10 rounded disabled:opacity-30"
+              title="Zoom out"
+            >
+              <ZoomOut className="w-3.5 h-3.5 text-white" />
+            </button>
+            <button
+              onClick={resetZoom}
+              className="px-1.5 py-0.5 text-xs text-white hover:bg-white/10 rounded min-w-[32px]"
+              title="Reset zoom"
             >
-              <path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z" />
-            </svg>
+              {Math.round(zoomLevel * 100)}%
+            </button>
+            <button
+              onClick={handleZoomIn}
+              disabled={zoomLevel >= 4}
+              className="p-1 hover:bg-white/10 rounded disabled:opacity-30"
+              title="Zoom in"
+            >
+              <ZoomIn className="w-3.5 h-3.5 text-white" />
+            </button>
           </div>
+
+          {/* Resize handle - hide in fullscreen */}
+          {!isFullscreen && (
+            <div
+              className="absolute bottom-0 right-0 w-6 h-6 cursor-se-resize no-drag hover:bg-white/10 rounded-tl transition-colors"
+              onMouseDown={handleResizeMouseDown}
+              title="Drag to resize"
+            >
+              <svg
+                className="w-6 h-6 text-bambu-gray/70 hover:text-bambu-gray"
+                viewBox="0 0 24 24"
+                fill="currentColor"
+              >
+                <path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22ZM22 14H20V12H22V14ZM18 18H16V16H18V18ZM14 22H12V20H14V22ZM22 10H20V8H22V10ZM18 14H16V12H18V14ZM14 18H12V16H14V18ZM10 22H8V20H10V22Z" />
+              </svg>
+            </div>
+          )}
         </div>
       )}
     </div>

+ 33 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 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, 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, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -9,6 +9,7 @@ import { useQuery } from '@tanstack/react-query';
 import { api, supportApi, pendingUploadsApi } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { useIsMobile } from '../hooks/useIsMobile';
+import { useAuth } from '../contexts/AuthContext';
 
 interface NavItem {
   id: string;
@@ -68,6 +69,7 @@ export function Layout() {
   const { mode, toggleMode } = useTheme();
   const { t } = useTranslation();
   const isMobile = useIsMobile();
+  const { user, authEnabled, logout } = useAuth();
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
     const stored = localStorage.getItem('sidebarExpanded');
     return stored !== 'false';
@@ -168,12 +170,20 @@ export function Layout() {
   const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
 
   // Compute the ordered sidebar: include stored order + any new items
+  // Filter out 'settings' for users with 'user' role
   const orderedSidebarIds = (() => {
     const result: string[] = [];
     const seen = new Set<string>();
 
+    // Determine if settings should be hidden (user role and auth enabled)
+    const hideSettings = authEnabled && user?.role === 'user';
+
     // Add items in stored order
     for (const id of sidebarOrder) {
+      // Skip settings if user is not admin
+      if (hideSettings && id === 'settings') {
+        continue;
+      }
       if (navItemsMap.has(id) || extLinksMap.has(id)) {
         result.push(id);
         seen.add(id);
@@ -182,6 +192,10 @@ export function Layout() {
 
     // Add any new internal nav items not in stored order
     for (const item of defaultNavItems) {
+      // Skip settings if user is not admin
+      if (hideSettings && item.id === 'settings') {
+        continue;
+      }
       if (!seen.has(item.id)) {
         result.push(item.id);
         seen.add(item.id);
@@ -565,6 +579,15 @@ export function Layout() {
                 >
                   {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 </button>
+                {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>
+                )}
               </div>
               {/* Bottom row: version */}
               <div className="flex items-center justify-center gap-2">
@@ -642,6 +665,15 @@ export function Layout() {
               >
                 {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
               </button>
+              {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>
+              )}
             </div>
           )}
         </div>

+ 135 - 0
frontend/src/contexts/AuthContext.tsx

@@ -0,0 +1,135 @@
+import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
+import { api, getAuthToken, setAuthToken } from '../api/client';
+import type { UserResponse } from '../api/client';
+
+interface AuthContextType {
+  user: UserResponse | null;
+  authEnabled: boolean;
+  requiresSetup: boolean;
+  loading: boolean;
+  login: (username: string, password: string) => Promise<void>;
+  logout: () => void;
+  refreshUser: () => Promise<void>;
+  refreshAuth: () => Promise<void>;
+}
+
+const AuthContext = createContext<AuthContextType | undefined>(undefined);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+  const [user, setUser] = useState<UserResponse | null>(null);
+  const [authEnabled, setAuthEnabled] = useState(false);
+  const [requiresSetup, setRequiresSetup] = useState(false);
+  const [loading, setLoading] = useState(true);
+  const hasRedirectedRef = useRef(false);
+
+  const checkAuthStatus = async () => {
+    try {
+      const status = await api.getAuthStatus();
+      setAuthEnabled(status.auth_enabled);
+      setRequiresSetup(status.requires_setup);
+
+      if (status.auth_enabled) {
+        const token = getAuthToken();
+        if (token) {
+          try {
+            const currentUser = await api.getCurrentUser();
+            setUser(currentUser);
+          } catch {
+            // Token invalid, clear it
+            setAuthToken(null);
+            setUser(null);
+          }
+        } else {
+          setUser(null);
+        }
+      } else {
+        // Auth not enabled, allow access
+        setUser(null);
+      }
+    } catch (error) {
+      console.error('Failed to check auth status:', error);
+      setAuthEnabled(false);
+      setUser(null);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    // Check auth status on mount
+    checkAuthStatus();
+  }, []);
+
+  // Separate effect to handle redirect only when setup is required
+  useEffect(() => {
+    // Only redirect if setup is truly required (first time setup)
+    // Don't redirect if user manually navigated to /setup or is on camera page
+    if (!loading && requiresSetup && !authEnabled) {
+      const currentPath = window.location.pathname;
+      // Only redirect if not already on setup page or camera page, and haven't redirected yet
+      if (currentPath !== '/setup' && !currentPath.startsWith('/camera/') && !hasRedirectedRef.current) {
+        hasRedirectedRef.current = true;
+        window.location.href = '/setup';
+      }
+    } else if (!requiresSetup) {
+      // Reset redirect flag when setup is no longer required
+      hasRedirectedRef.current = false;
+    }
+  }, [loading, requiresSetup, authEnabled]);
+
+  const login = async (username: string, password: string) => {
+    const response = await api.login({ username, password });
+    setAuthToken(response.access_token);
+    setUser(response.user);
+  };
+
+  const logout = () => {
+    setAuthToken(null);
+    setUser(null);
+    api.logout().catch(() => {
+      // Ignore logout errors
+    });
+    window.location.href = '/login';
+  };
+
+  const refreshUser = async () => {
+    if (authEnabled && getAuthToken()) {
+      try {
+        const currentUser = await api.getCurrentUser();
+        setUser(currentUser);
+      } catch {
+        setAuthToken(null);
+        setUser(null);
+      }
+    }
+  };
+
+  const refreshAuth = async () => {
+    await checkAuthStatus();
+  };
+
+  return (
+    <AuthContext.Provider
+      value={{
+        user,
+        authEnabled,
+        requiresSetup,
+        loading,
+        login,
+        logout,
+        refreshUser,
+        refreshAuth,
+      }}
+    >
+      {children}
+    </AuthContext.Provider>
+  );
+}
+
+export function useAuth() {
+  const context = useContext(AuthContext);
+  if (context === undefined) {
+    throw new Error('useAuth must be used within an AuthProvider');
+  }
+  return context;
+}

+ 103 - 3
frontend/src/pages/CameraPage.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useParams } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
-import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff } from 'lucide-react';
+import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
 import { api } from '../api/client';
 
 const MAX_RECONNECT_ATTEMPTS = 5;
@@ -22,6 +22,10 @@ export function CameraPage() {
   const [reconnectAttempts, setReconnectAttempts] = useState(0);
   const [isReconnecting, setIsReconnecting] = useState(false);
   const [reconnectCountdown, setReconnectCountdown] = useState(0);
+  const [zoomLevel, setZoomLevel] = useState(1);
+  const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
+  const [isPanning, setIsPanning] = useState(false);
+  const [panStart, setPanStart] = useState({ x: 0, y: 0 });
   const imgRef = useRef<HTMLImageElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
   const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -98,6 +102,9 @@ export function CameraPage() {
     const handleFullscreenChange = () => {
       const nowFullscreen = !!document.fullscreenElement;
       setIsFullscreen(nowFullscreen);
+      // Reset zoom on fullscreen transition
+      setZoomLevel(1);
+      setPanOffset({ x: 0, y: 0 });
 
       // Refresh stream after fullscreen transition to prevent stall
       if (streamMode === 'stream' && !transitioning) {
@@ -307,6 +314,9 @@ export function CameraPage() {
     // Reset reconnect state on mode switch
     setReconnectAttempts(0);
     setIsReconnecting(false);
+    // Reset zoom on mode switch
+    setZoomLevel(1);
+    setPanOffset({ x: 0, y: 0 });
     if (reconnectTimerRef.current) {
       clearTimeout(reconnectTimerRef.current);
     }
@@ -369,6 +379,57 @@ export function CameraPage() {
     }
   };
 
+  const handleZoomIn = () => {
+    setZoomLevel(prev => Math.min(prev + 0.5, 4));
+  };
+
+  const handleZoomOut = () => {
+    setZoomLevel(prev => {
+      const newZoom = Math.max(prev - 0.5, 1);
+      if (newZoom === 1) setPanOffset({ x: 0, y: 0 });
+      return newZoom;
+    });
+  };
+
+  const handleWheel = (e: React.WheelEvent) => {
+    e.preventDefault();
+    if (e.deltaY < 0) {
+      handleZoomIn();
+    } else {
+      handleZoomOut();
+    }
+  };
+
+  const handleImageMouseDown = (e: React.MouseEvent) => {
+    if (zoomLevel > 1) {
+      e.preventDefault();
+      setIsPanning(true);
+      setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y });
+    }
+  };
+
+  const handleImageMouseMove = (e: React.MouseEvent) => {
+    if (isPanning && zoomLevel > 1) {
+      const newX = e.clientX - panStart.x;
+      const newY = e.clientY - panStart.y;
+      // Limit panning based on zoom level
+      const maxPan = (zoomLevel - 1) * 200;
+      setPanOffset({
+        x: Math.max(-maxPan, Math.min(maxPan, newX)),
+        y: Math.max(-maxPan, Math.min(maxPan, newY)),
+      });
+    }
+  };
+
+  const handleImageMouseUp = () => {
+    setIsPanning(false);
+  };
+
+  const resetZoom = () => {
+    setZoomLevel(1);
+    setPanOffset({ x: 0, y: 0 });
+  };
+
   const currentUrl = transitioning
     ? ''
     : streamMode === 'stream'
@@ -442,7 +503,13 @@ export function CameraPage() {
       </div>
 
       {/* Video area */}
-      <div className="flex-1 flex items-center justify-center p-2">
+      <div
+        className="flex-1 flex items-center justify-center p-2 overflow-hidden"
+        onWheel={handleWheel}
+        onMouseMove={handleImageMouseMove}
+        onMouseUp={handleImageMouseUp}
+        onMouseLeave={handleImageMouseUp}
+      >
         <div className="relative w-full h-full flex items-center justify-center">
           {(streamLoading || transitioning) && !isReconnecting && (
             <div className="absolute inset-0 flex items-center justify-center bg-black/50 z-10">
@@ -493,10 +560,43 @@ export function CameraPage() {
             key={imageKey}
             src={currentUrl}
             alt="Camera stream"
-            className="max-w-full max-h-full object-contain"
+            className="max-w-full max-h-full object-contain select-none"
+            style={{
+              transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
+              cursor: zoomLevel > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default',
+            }}
             onError={currentUrl ? handleStreamError : undefined}
             onLoad={currentUrl ? handleStreamLoad : undefined}
+            onMouseDown={handleImageMouseDown}
+            draggable={false}
           />
+
+          {/* Zoom controls */}
+          <div className="absolute bottom-4 left-4 flex items-center gap-1.5 bg-black/60 rounded-lg px-2 py-1.5">
+            <button
+              onClick={handleZoomOut}
+              disabled={zoomLevel <= 1}
+              className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
+              title="Zoom out"
+            >
+              <ZoomOut className="w-4 h-4 text-white" />
+            </button>
+            <button
+              onClick={resetZoom}
+              className="px-2 py-1 text-sm text-white hover:bg-white/10 rounded min-w-[48px]"
+              title="Reset zoom"
+            >
+              {Math.round(zoomLevel * 100)}%
+            </button>
+            <button
+              onClick={handleZoomIn}
+              disabled={zoomLevel >= 4}
+              className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
+              title="Zoom in"
+            >
+              <ZoomIn className="w-4 h-4 text-white" />
+            </button>
+          </div>
         </div>
       </div>
     </div>

+ 86 - 31
frontend/src/pages/FileManagerPage.tsx

@@ -14,7 +14,6 @@ import {
   FileBox,
   Clock,
   HardDrive,
-  Copy,
   File,
   MoveRight,
   CheckSquare,
@@ -418,12 +417,15 @@ interface UploadFile {
   file: File;
   status: 'pending' | 'uploading' | 'success' | 'error';
   error?: string;
+  isZip?: boolean;
+  extractedCount?: number;
 }
 
 function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps) {
   const [files, setFiles] = useState<UploadFile[]>([]);
   const [isDragging, setIsDragging] = useState(false);
   const [isUploading, setIsUploading] = useState(false);
+  const [preserveZipStructure, setPreserveZipStructure] = useState(true);
   const fileInputRef = useRef<HTMLInputElement>(null);
 
   const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
@@ -453,6 +455,7 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
     const uploadFiles: UploadFile[] = newFiles.map((file) => ({
       file,
       status: 'pending',
+      isZip: file.name.toLowerCase().endsWith('.zip'),
     }));
     setFiles((prev) => [...prev, ...uploadFiles]);
   };
@@ -461,6 +464,8 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
     setFiles((prev) => prev.filter((_, i) => i !== index));
   };
 
+  const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
+
   const handleUpload = async () => {
     if (files.length === 0) return;
 
@@ -474,10 +479,28 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
       );
 
       try {
-        await api.uploadLibraryFile(files[i].file, folderId);
-        setFiles((prev) =>
-          prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
-        );
+        if (files[i].isZip) {
+          // Extract ZIP file
+          const result = await api.extractZipFile(files[i].file, folderId, preserveZipStructure);
+          setFiles((prev) =>
+            prev.map((f, idx) =>
+              idx === i
+                ? {
+                    ...f,
+                    status: result.errors.length > 0 && result.extracted === 0 ? 'error' : 'success',
+                    extractedCount: result.extracted,
+                    error: result.errors.length > 0 ? `${result.errors.length} files failed` : undefined,
+                  }
+                : f
+            )
+          );
+        } else {
+          // Regular file upload
+          await api.uploadLibraryFile(files[i].file, folderId);
+          setFiles((prev) =>
+            prev.map((f, idx) => (idx === i ? { ...f, status: 'success' } : f))
+          );
+        }
       } catch (err) {
         setFiles((prev) =>
           prev.map((f, idx) =>
@@ -528,16 +551,42 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
               {isDragging ? 'Drop files here' : 'Drag & drop files here'}
             </p>
             <p className="text-sm text-bambu-gray mt-1">or click to browse</p>
+            <p className="text-xs text-bambu-gray/70 mt-2">ZIP files will be automatically extracted</p>
           </div>
 
           <input
             ref={fileInputRef}
             type="file"
             multiple
+            accept="*/*,.zip"
             className="hidden"
             onChange={handleFileSelect}
           />
 
+          {/* ZIP Options */}
+          {hasZipFiles && (
+            <div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
+              <div className="flex items-start gap-3">
+                <ArchiveIcon className="w-5 h-5 text-blue-400 mt-0.5 flex-shrink-0" />
+                <div className="flex-1">
+                  <p className="text-sm text-blue-300 font-medium">ZIP files detected</p>
+                  <p className="text-xs text-blue-300/70 mt-1">
+                    ZIP files will be extracted. Choose how to handle folder structure:
+                  </p>
+                  <label className="flex items-center gap-2 mt-2 cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={preserveZipStructure}
+                      onChange={(e) => setPreserveZipStructure(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">Preserve folder structure from ZIP</span>
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+
           {/* File List */}
           {files.length > 0 && (
             <div className="max-h-48 overflow-y-auto space-y-2">
@@ -546,11 +595,21 @@ function UploadModal({ folderId, onClose, onUploadComplete }: UploadModalProps)
                   key={index}
                   className="flex items-center gap-3 p-2 bg-bambu-dark rounded-lg"
                 >
-                  <File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                  {uploadFile.isZip ? (
+                    <ArchiveIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
+                  ) : (
+                    <File className="w-4 h-4 text-bambu-gray flex-shrink-0" />
+                  )}
                   <div className="flex-1 min-w-0">
                     <p className="text-sm text-white truncate">{uploadFile.file.name}</p>
                     <p className="text-xs text-bambu-gray">
                       {(uploadFile.file.size / 1024 / 1024).toFixed(2)} MB
+                      {uploadFile.isZip && uploadFile.status === 'pending' && (
+                        <span className="text-blue-400 ml-2">• Will be extracted</span>
+                      )}
+                      {uploadFile.extractedCount !== undefined && (
+                        <span className="text-green-400 ml-2">• {uploadFile.extractedCount} files extracted</span>
+                      )}
                     </p>
                   </div>
                   {uploadFile.status === 'pending' && (
@@ -787,13 +846,6 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
         ) : (
           <FileBox className="w-12 h-12 text-bambu-gray/30" />
         )}
-        {/* Duplicate badge */}
-        {file.duplicate_count > 0 && (
-          <div className="absolute top-2 left-2 flex items-center gap-1 bg-amber-500/90 text-white text-xs px-1.5 py-0.5 rounded">
-            <Copy className="w-3 h-3" />
-            {file.duplicate_count}
-          </div>
-        )}
         {/* File type badge */}
         <div className={`absolute top-2 right-2 text-xs px-1.5 py-0.5 rounded font-medium ${
           file.file_type === '3mf' ? 'bg-bambu-green/90 text-white'
@@ -1061,6 +1113,22 @@ export function FileManagerPage() {
     },
   });
 
+  const bulkDeleteMutation = useMutation({
+    mutationFn: (fileIds: number[]) => api.bulkDeleteLibrary(fileIds, []),
+    onSuccess: (_, fileIds) => {
+      queryClient.invalidateQueries({ queryKey: ['library-files'] });
+      queryClient.invalidateQueries({ queryKey: ['library-folders'] });
+      queryClient.invalidateQueries({ queryKey: ['library-stats'] });
+      showToast(`Deleted ${fileIds.length} files`, 'success');
+      setSelectedFiles([]);
+      setDeleteConfirm(null);
+    },
+    onError: (error: Error) => {
+      setDeleteConfirm(null);
+      showToast(error.message, 'error');
+    },
+  });
+
   const moveFilesMutation = useMutation({
     mutationFn: ({ fileIds, folderId }: { fileIds: number[]; folderId: number | null }) =>
       api.moveLibraryFiles(fileIds, folderId),
@@ -1191,21 +1259,12 @@ export function FileManagerPage() {
     } else if (deleteConfirm.type === 'folder') {
       deleteFolderMutation.mutate(deleteConfirm.id);
     } else if (deleteConfirm.type === 'bulk') {
-      // Bulk delete selected files
-      api.bulkDeleteLibrary(selectedFiles, []).then(() => {
-        queryClient.invalidateQueries({ queryKey: ['library-files'] });
-        queryClient.invalidateQueries({ queryKey: ['library-folders'] });
-        queryClient.invalidateQueries({ queryKey: ['library-stats'] });
-        showToast(`Deleted ${selectedFiles.length} files`, 'success');
-        setSelectedFiles([]);
-        setDeleteConfirm(null);
-      }).catch((err) => {
-        showToast(err.message, 'error');
-        setDeleteConfirm(null);
-      });
+      bulkDeleteMutation.mutate(selectedFiles);
     }
   };
 
+  const isDeleting = deleteFolderMutation.isPending || deleteFileMutation.isPending || bulkDeleteMutation.isPending;
+
   const handleViewModeChange = (mode: 'grid' | 'list') => {
     setViewMode(mode);
     localStorage.setItem('library-view-mode', mode);
@@ -1609,12 +1668,6 @@ export function FileManagerPage() {
                       </div>
                       <div className="min-w-0">
                         <div className="text-sm text-white truncate">{file.print_name || file.filename}</div>
-                        {file.duplicate_count > 0 && (
-                          <div className="flex items-center gap-1 text-xs text-amber-400">
-                            <Copy className="w-3 h-3" />
-                            {file.duplicate_count} duplicate(s)
-                          </div>
-                        )}
                       </div>
                     </div>
                     {/* Type */}
@@ -1739,6 +1792,8 @@ export function FileManagerPage() {
           }
           confirmText="Delete"
           variant="danger"
+          isLoading={isDeleting}
+          loadingText="Deleting..."
           onConfirm={handleDeleteConfirm}
           onCancel={() => setDeleteConfirm(null)}
         />

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

@@ -0,0 +1,103 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useMutation } from '@tanstack/react-query';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { useTheme } from '../contexts/ThemeContext';
+
+export function LoginPage() {
+  const navigate = useNavigate();
+  const { login } = useAuth();
+  const { showToast } = useToast();
+  const { mode } = useTheme();
+  const [username, setUsername] = useState('');
+  const [password, setPassword] = useState('');
+
+  const loginMutation = useMutation({
+    mutationFn: () => login(username, password),
+    onSuccess: () => {
+      showToast('Logged in successfully');
+      navigate('/');
+    },
+    onError: (error: Error) => {
+      showToast(error.message || 'Login failed', 'error');
+    },
+  });
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!username || !password) {
+      showToast('Please enter username and password', 'error');
+      return;
+    }
+    loginMutation.mutate();
+  };
+
+  return (
+    <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
+      <div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
+        <div className="text-center">
+          <div className="flex items-center justify-center mb-6">
+            <img
+              src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
+              alt="Bambuddy"
+              className="h-16"
+            />
+          </div>
+          <h2 className="text-3xl font-bold text-white">
+            Bambuddy Login
+          </h2>
+          <p className="mt-2 text-sm text-bambu-gray">
+            Sign in to your account
+          </p>
+        </div>
+
+        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
+          <div className="space-y-4">
+            <div>
+              <label htmlFor="username" className="block text-sm font-medium text-white mb-2">
+                Username
+              </label>
+              <input
+                id="username"
+                type="text"
+                required
+                value={username}
+                onChange={(e) => setUsername(e.target.value)}
+                className="block 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 your username"
+                autoComplete="username"
+              />
+            </div>
+
+            <div>
+              <label htmlFor="password" className="block text-sm font-medium text-white mb-2">
+                Password
+              </label>
+              <input
+                id="password"
+                type="password"
+                required
+                value={password}
+                onChange={(e) => setPassword(e.target.value)}
+                className="block 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 your password"
+                autoComplete="current-password"
+              />
+            </div>
+          </div>
+
+          <div>
+            <button
+              type="submit"
+              disabled={loginMutation.isPending}
+              className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
+            >
+              {loginMutation.isPending ? 'Logging in...' : 'Sign in'}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 11 - 6
frontend/src/pages/PrintersPage.tsx

@@ -2684,7 +2684,7 @@ function PrinterCard({
           <div className="absolute inset-0 bg-black/50 z-0" />
           {/* Modal */}
           <div
-            className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] overflow-hidden"
+            className="relative z-10 bg-white dark:bg-bambu-dark border border-gray-200 dark:border-bambu-dark-tertiary rounded-xl shadow-2xl w-[560px] max-h-[85vh] flex flex-col overflow-hidden"
             onClick={(e) => e.stopPropagation()}
           >
           {/* Header */}
@@ -2711,7 +2711,7 @@ function PrinterCard({
               <p className="text-xs mt-1 opacity-70">Objects are loaded when a print starts</p>
             </div>
           ) : (
-            <div className="flex flex-col">
+            <div className="flex flex-col overflow-hidden">
               {/* Info Banner */}
               <div className="flex items-center gap-3 px-4 py-2.5 bg-blue-50 dark:bg-blue-500/10 border-b border-gray-200 dark:border-bambu-dark-tertiary">
                 <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-500/20 flex items-center justify-center">
@@ -2737,9 +2737,9 @@ function PrinterCard({
               )}
 
               {/* Content: Image + List side by side */}
-              <div className="flex">
+              <div className="flex flex-1 overflow-hidden">
                 {/* Left: Preview Image with object markers */}
-                <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary">
+                <div className="w-52 flex-shrink-0 p-4 border-r border-gray-200 dark:border-bambu-dark-tertiary bg-gray-50 dark:bg-bambu-dark-secondary overflow-y-auto">
                   <div className="relative">
                     {status?.cover_url ? (
                       <img
@@ -2824,7 +2824,7 @@ function PrinterCard({
                 </div>
 
                 {/* Right: Object List with prominent IDs */}
-                <div className="flex-1 min-w-0">
+                <div className="flex-1 min-w-0 overflow-y-auto">
                   {objectsData.objects.map((obj) => (
                     <div
                       key={obj.id}
@@ -3110,10 +3110,12 @@ function AddPrinterModal({
   };
 
   const selectPrinter = (printer: DiscoveredPrinter) => {
+    // Don't pre-fill serial if it's a placeholder (unknown-*) - user needs to enter actual serial
+    const serialNumber = printer.serial.startsWith('unknown-') ? '' : printer.serial;
     setForm({
       ...form,
       name: printer.name || '',
-      serial_number: printer.serial,
+      serial_number: serialNumber,
       ip_address: printer.ip_address,
       model: mapModelCode(printer.model),
     });
@@ -3209,6 +3211,9 @@ function AddPrinterModal({
                       </p>
                       <p className="text-xs text-bambu-gray truncate">
                         {mapModelCode(printer.model) || 'Unknown'} • {printer.ip_address}
+                        {printer.serial.startsWith('unknown-') && (
+                          <span className="text-yellow-500"> • Serial required</span>
+                        )}
                       </p>
                     </div>
                     <ChevronDown className="w-4 h-4 text-bambu-gray -rotate-90 flex-shrink-0 ml-2" />

+ 316 - 15
frontend/src/pages/SettingsPage.tsx

@@ -1,7 +1,9 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
+import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
 import { formatDateOnly } from '../utils/date';
 import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
@@ -27,10 +29,16 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { Palette } from 'lucide-react';
 
+const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users'] as const;
+type TabType = typeof validTabs[number];
+
 export function SettingsPage() {
   const queryClient = useQueryClient();
+  const navigate = useNavigate();
+  const [searchParams, setSearchParams] = useSearchParams();
   const { t, i18n } = useTranslation();
   const { showToast, showPersistentToast, dismissToast } = useToast();
+  const { authEnabled, user, refreshAuth } = useAuth();
   const {
     mode,
     darkStyle, darkBackground, darkAccent,
@@ -46,7 +54,22 @@ export function SettingsPage() {
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
-  const [activeTab, setActiveTab] = useState<'general' | 'network' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer'>('general');
+
+  // Initialize tab from URL params
+  const tabParam = searchParams.get('tab');
+  const initialTab = tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general';
+  const [activeTab, setActiveTab] = useState<TabType>(initialTab);
+
+  // Update URL when tab changes
+  const handleTabChange = (tab: TabType) => {
+    setActiveTab(tab);
+    if (tab === 'general') {
+      searchParams.delete('tab');
+    } else {
+      searchParams.set('tab', tab);
+    }
+    setSearchParams(searchParams, { replace: true });
+  };
   const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -66,6 +89,7 @@ export function SettingsPage() {
   const [showRestoreModal, setShowRestoreModal] = useState(false);
   const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
   const [showReleaseNotes, setShowReleaseNotes] = useState(false);
+  const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false);
 
   // Home Assistant test connection state
   const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
@@ -310,7 +334,12 @@ export function SettingsPage() {
   // Sync local state when settings load
   useEffect(() => {
     if (settings && !localSettings) {
-      setLocalSettings(settings);
+      // Auto-detect external_url from browser if not set
+      const settingsWithExternalUrl = {
+        ...settings,
+        external_url: settings.external_url || window.location.origin,
+      };
+      setLocalSettings(settingsWithExternalUrl);
       // Mark initial load complete after a short delay
       setTimeout(() => {
         isInitialLoadRef.current = false;
@@ -376,6 +405,7 @@ export function SettingsPage() {
       settings.mqtt_password !== localSettings.mqtt_password ||
       settings.mqtt_topic_prefix !== localSettings.mqtt_topic_prefix ||
       settings.mqtt_use_tls !== localSettings.mqtt_use_tls ||
+      settings.external_url !== localSettings.external_url ||
       settings.ha_enabled !== localSettings.ha_enabled ||
       settings.ha_url !== localSettings.ha_url ||
       settings.ha_token !== localSettings.ha_token ||
@@ -436,6 +466,7 @@ export function SettingsPage() {
         mqtt_password: localSettings.mqtt_password,
         mqtt_topic_prefix: localSettings.mqtt_topic_prefix,
         mqtt_use_tls: localSettings.mqtt_use_tls,
+        external_url: localSettings.external_url,
         ha_enabled: localSettings.ha_enabled,
         ha_url: localSettings.ha_url,
         ha_token: localSettings.ha_token,
@@ -474,9 +505,9 @@ export function SettingsPage() {
       </div>
 
       {/* Tab Navigation */}
-      <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
+      <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary overflow-x-auto">
         <button
-          onClick={() => setActiveTab('general')}
+          onClick={() => handleTabChange('general')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
             activeTab === 'general'
               ? 'text-bambu-green border-bambu-green'
@@ -486,7 +517,7 @@ export function SettingsPage() {
           General
         </button>
         <button
-          onClick={() => setActiveTab('plugs')}
+          onClick={() => handleTabChange('plugs')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
             activeTab === 'plugs'
               ? 'text-bambu-green border-bambu-green'
@@ -502,7 +533,7 @@ export function SettingsPage() {
           )}
         </button>
         <button
-          onClick={() => setActiveTab('notifications')}
+          onClick={() => handleTabChange('notifications')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
             activeTab === 'notifications'
               ? 'text-bambu-green border-bambu-green'
@@ -518,7 +549,7 @@ export function SettingsPage() {
           )}
         </button>
         <button
-          onClick={() => setActiveTab('filament')}
+          onClick={() => handleTabChange('filament')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
             activeTab === 'filament'
               ? 'text-bambu-green border-bambu-green'
@@ -529,7 +560,7 @@ export function SettingsPage() {
           Filament
         </button>
         <button
-          onClick={() => setActiveTab('network')}
+          onClick={() => handleTabChange('network')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
             activeTab === 'network'
               ? 'text-bambu-green border-bambu-green'
@@ -541,7 +572,7 @@ export function SettingsPage() {
           <span className={`w-2 h-2 rounded-full ${mqttStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
         </button>
         <button
-          onClick={() => setActiveTab('apikeys')}
+          onClick={() => handleTabChange('apikeys')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
             activeTab === 'apikeys'
               ? 'text-bambu-green border-bambu-green'
@@ -557,7 +588,7 @@ export function SettingsPage() {
           )}
         </button>
         <button
-          onClick={() => setActiveTab('virtual-printer')}
+          onClick={() => handleTabChange('virtual-printer')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
             activeTab === 'virtual-printer'
               ? 'text-bambu-green border-bambu-green'
@@ -568,6 +599,20 @@ export function SettingsPage() {
           Virtual Printer
           <span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
         </button>
+        <button
+          onClick={() => handleTabChange('users')}
+          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+            activeTab === 'users'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+          }`}
+        >
+          <Users className="w-4 h-4" />
+          Users
+          {authEnabled && (
+            <span className={`w-2 h-2 rounded-full ${authEnabled ? 'bg-green-400' : 'bg-gray-500'}`} />
+          )}
+        </button>
       </div>
 
       {/* General Tab */}
@@ -1286,8 +1331,38 @@ export function SettingsPage() {
       {/* Network Tab */}
       {activeTab === 'network' && localSettings && (
       <div className="flex flex-col lg:flex-row gap-6">
-        {/* Left Column - FTP Retry & Home Assistant */}
+        {/* Left Column - External URL & FTP Retry */}
         <div className="flex-1 lg:max-w-xl space-y-4">
+          {/* External URL */}
+          <Card>
+            <CardHeader>
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <Globe className="w-5 h-5 text-blue-400" />
+                External URL
+              </h2>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-sm text-bambu-gray">
+                The external URL where Bambuddy is accessible. Used for notification images and external integrations.
+              </p>
+              <div>
+                <label className="block text-sm text-bambu-gray mb-1">
+                  Bambuddy URL
+                </label>
+                <input
+                  type="text"
+                  value={localSettings.external_url ?? ''}
+                  onChange={(e) => updateSetting('external_url', e.target.value)}
+                  placeholder="http://192.168.1.100:8000"
+                  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"
+                />
+                <p className="text-xs text-bambu-gray mt-1">
+                  Include protocol and port (e.g., http://192.168.1.100:8000)
+                </p>
+              </div>
+            </CardContent>
+          </Card>
+
           <Card>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white flex items-center gap-2">
@@ -1384,6 +1459,10 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
+        </div>
+
+        {/* Right Column - Home Assistant & MQTT Publishing */}
+        <div className="flex-1 lg:max-w-xl space-y-4">
           {/* Home Assistant Integration */}
           <Card>
             <CardHeader>
@@ -1482,10 +1561,8 @@ export function SettingsPage() {
               )}
             </CardContent>
           </Card>
-        </div>
 
-        {/* Right Column - MQTT Publishing */}
-        <div className="flex-1 lg:max-w-xl space-y-4">
+          {/* MQTT Publishing */}
           <Card>
             <CardHeader>
               <div className="flex items-center justify-between">
@@ -2866,6 +2943,230 @@ export function SettingsPage() {
           </Card>
         </div>
       )}
+
+      {/* Users Tab */}
+      {activeTab === 'users' && (
+        <div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
+          <div>
+            <div className="mb-6">
+              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                <Users className="w-5 h-5 text-bambu-green" />
+                User Authentication
+              </h2>
+              <p className="text-sm text-bambu-gray mt-1">
+                Enable authentication to secure your Bambuddy instance and manage user access.
+              </p>
+            </div>
+
+            <Card>
+              <CardContent className="py-6">
+                {!authEnabled ? (
+                  <div className="space-y-4">
+                    <div className="flex items-center gap-3">
+                      <div className={`w-12 h-12 rounded-full flex items-center justify-center ${authEnabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
+                        {authEnabled ? (
+                          <Lock className="w-6 h-6 text-green-400" />
+                        ) : (
+                          <Unlock className="w-6 h-6 text-gray-400" />
+                        )}
+                      </div>
+                      <div className="flex-1">
+                        <h3 className="text-white font-medium">Authentication Disabled</h3>
+                        <p className="text-sm text-bambu-gray">
+                          Your Bambuddy instance is currently accessible without authentication.
+                        </p>
+                      </div>
+                    </div>
+
+                    <div className="pt-4 border-t border-bambu-dark-tertiary">
+                      <p className="text-sm text-bambu-gray mb-4">
+                        Enable authentication to:
+                      </p>
+                      <ul className="space-y-2 text-sm text-bambu-gray mb-4">
+                        <li className="flex items-start gap-2">
+                          <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                          <span>Require login to access the system</span>
+                        </li>
+                        <li className="flex items-start gap-2">
+                          <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                          <span>Manage multiple users with different roles</span>
+                        </li>
+                        <li className="flex items-start gap-2">
+                          <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                          <span>Control access to printer settings and user management</span>
+                        </li>
+                      </ul>
+
+                      <Button
+                        type="button"
+                        onClick={(e) => {
+                          e.preventDefault();
+                          navigate('/setup');
+                        }}
+                        className="w-full"
+                      >
+                        <Lock className="w-4 h-4" />
+                        Activate Authentication
+                      </Button>
+                    </div>
+                  </div>
+                ) : (
+                  <div className="space-y-4">
+                    <div className="flex items-center gap-3">
+                      <div className="w-12 h-12 rounded-full flex items-center justify-center bg-green-500/20">
+                        <Lock className="w-6 h-6 text-green-400" />
+                      </div>
+                      <div className="flex-1">
+                        <h3 className="text-white font-medium">Authentication Enabled</h3>
+                        <p className="text-sm text-bambu-gray">
+                          Your Bambuddy instance is secured with authentication.
+                        </p>
+                      </div>
+                    </div>
+
+                    {user && (
+                      <div className="pt-4 border-t border-bambu-dark-tertiary">
+                        <div className="flex items-center justify-between mb-4">
+                          <div>
+                            <p className="text-sm text-bambu-gray">Current User</p>
+                            <p className="text-white font-medium">{user.username}</p>
+                            <p className="text-xs text-bambu-gray mt-1">
+                              Role: <span className="capitalize">{user.role}</span>
+                            </p>
+                          </div>
+                          <div 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 === 'admin' ? 'Admin' : 'User'}
+                          </div>
+                        </div>
+                      </div>
+                    )}
+
+                    <div className="pt-4 border-t border-bambu-dark-tertiary space-y-3">
+                      <Button
+                        onClick={() => navigate('/users')}
+                        className="w-full"
+                        variant="secondary"
+                      >
+                        <Users className="w-4 h-4" />
+                        Manage Users
+                      </Button>
+
+                      {user?.role === 'admin' && (
+                        <Button
+                          onClick={() => setShowDisableAuthConfirm(true)}
+                          className="w-full"
+                          variant="secondary"
+                        >
+                          <Unlock className="w-4 h-4" />
+                          Disable Authentication
+                        </Button>
+                      )}
+                    </div>
+                  </div>
+                )}
+              </CardContent>
+            </Card>
+          </div>
+
+          {authEnabled && (
+            <div>
+              <div className="mb-6">
+                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                  <Shield className="w-5 h-5 text-bambu-green" />
+                  Role Permissions
+                </h2>
+                <p className="text-sm text-bambu-gray mt-1">
+                  Overview of what each role can do.
+                </p>
+              </div>
+
+              <div className="space-y-4">
+                <Card>
+                  <CardHeader>
+                    <div className="flex items-center gap-2">
+                      <div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center">
+                        <Shield className="w-4 h-4 text-purple-300" />
+                      </div>
+                      <h3 className="text-white font-medium">Admin</h3>
+                    </div>
+                  </CardHeader>
+                  <CardContent>
+                    <ul className="space-y-2 text-sm text-bambu-gray">
+                      <li className="flex items-start gap-2">
+                        <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                        <span>Manage printer settings</span>
+                      </li>
+                      <li className="flex items-start gap-2">
+                        <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                        <span>Create, edit, and delete users</span>
+                      </li>
+                      <li className="flex items-start gap-2">
+                        <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                        <span>Access all system features</span>
+                      </li>
+                    </ul>
+                  </CardContent>
+                </Card>
+
+                <Card>
+                  <CardHeader>
+                    <div className="flex items-center gap-2">
+                      <div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center">
+                        <Users className="w-4 h-4 text-blue-300" />
+                      </div>
+                      <h3 className="text-white font-medium">User</h3>
+                    </div>
+                  </CardHeader>
+                  <CardContent>
+                    <ul className="space-y-2 text-sm text-bambu-gray">
+                      <li className="flex items-start gap-2">
+                        <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                        <span>Send print jobs</span>
+                      </li>
+                      <li className="flex items-start gap-2">
+                        <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                        <span>Manage files and archives</span>
+                      </li>
+                      <li className="flex items-start gap-2">
+                        <CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                        <span>Manage filament</span>
+                      </li>
+                    </ul>
+                  </CardContent>
+                </Card>
+              </div>
+            </div>
+          )}
+        </div>
+      )}
+
+      {/* Disable Authentication Confirmation Modal */}
+      {showDisableAuthConfirm && (
+        <ConfirmModal
+          title="Disable Authentication"
+          message="Are you sure you want to disable authentication? This will make your Bambuddy instance accessible without login. All users will remain in the database but authentication will be disabled."
+          confirmText="Disable Authentication"
+          variant="danger"
+          onConfirm={async () => {
+            try {
+              await api.disableAuth();
+              showToast('Authentication disabled successfully', 'success');
+              await refreshAuth();
+              setShowDisableAuthConfirm(false);
+              // Refresh the page to ensure all protected routes are accessible
+              window.location.href = '/';
+            } catch (error: unknown) {
+              const message = error instanceof Error ? error.message : 'Failed to disable authentication';
+              showToast(message, 'error');
+            }
+          }}
+          onCancel={() => setShowDisableAuthConfirm(false)}
+        />
+      )}
     </div>
   );
 }

+ 188 - 0
frontend/src/pages/SetupPage.tsx

@@ -0,0 +1,188 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useMutation } from '@tanstack/react-query';
+import { api } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+import { useTheme } from '../contexts/ThemeContext';
+import { useAuth } from '../contexts/AuthContext';
+import { Info } from 'lucide-react';
+
+export function SetupPage() {
+  const navigate = useNavigate();
+  const { showToast } = useToast();
+  const { mode } = useTheme();
+  const { refreshAuth } = useAuth();
+  const [authEnabled, setAuthEnabled] = useState(false);
+  const [adminUsername, setAdminUsername] = useState('');
+  const [adminPassword, setAdminPassword] = useState('');
+  const [confirmPassword, setConfirmPassword] = useState('');
+
+  const setupMutation = useMutation({
+    mutationFn: () =>
+      api.setupAuth({
+        auth_enabled: authEnabled,
+        admin_username: authEnabled ? adminUsername : undefined,
+        admin_password: authEnabled ? adminPassword : undefined,
+      }),
+    onSuccess: async (data) => {
+      // Refresh auth status after setup
+      await refreshAuth();
+
+      if (data.auth_enabled) {
+        if (data.admin_created) {
+          showToast('Authentication enabled and admin user created');
+          navigate('/login');
+        } else {
+          showToast('Authentication enabled using existing admin users');
+          navigate('/login');
+        }
+      } else {
+        showToast('Setup completed');
+        navigate('/');
+      }
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+
+    if (authEnabled) {
+      // Only validate if credentials are provided
+      // If no credentials provided, backend will use existing admin users if they exist
+      if (adminUsername || adminPassword) {
+        if (!adminUsername || !adminPassword) {
+          showToast('Please enter both admin username and password, or leave both empty to use existing admin users', 'error');
+          return;
+        }
+        if (adminPassword !== confirmPassword) {
+          showToast('Passwords do not match', 'error');
+          return;
+        }
+        if (adminPassword.length < 6) {
+          showToast('Password must be at least 6 characters', 'error');
+          return;
+        }
+      }
+    }
+
+    setupMutation.mutate();
+  };
+
+  return (
+    <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
+      <div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
+        <div className="text-center">
+          <div className="flex items-center justify-center mb-6">
+            <img
+              src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
+              alt="Bambuddy"
+              className="h-16"
+            />
+          </div>
+          <h2 className="text-3xl font-bold text-white">
+            Bambuddy Setup
+          </h2>
+          <p className="mt-2 text-sm text-bambu-gray">
+            Configure authentication for your Bambuddy instance
+          </p>
+        </div>
+
+        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
+          <div className="space-y-4">
+            <div className="flex items-center p-4 bg-bambu-dark-secondary/50 rounded-lg border border-bambu-dark-tertiary">
+              <input
+                id="auth-enabled"
+                type="checkbox"
+                checked={authEnabled}
+                onChange={(e) => setAuthEnabled(e.target.checked)}
+                className="h-4 w-4 text-bambu-green focus:ring-bambu-green border-bambu-dark-tertiary rounded bg-bambu-dark-secondary"
+              />
+              <label htmlFor="auth-enabled" className="ml-3 block text-sm font-medium text-white">
+                Enable Authentication
+              </label>
+            </div>
+
+            {authEnabled && (
+              <div className="space-y-4 mt-4">
+                <div className="p-3 bg-bambu-dark-secondary/50 border border-bambu-dark-tertiary rounded-lg">
+                  <div className="flex items-start gap-2">
+                    <Info className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
+                    <div className="text-sm text-bambu-gray">
+                      <p className="text-white font-medium mb-1">Admin Account</p>
+                      <p>
+                        If admin users already exist, authentication will be enabled using the existing admin accounts.
+                        Leave the fields below empty to use existing admins, or enter new credentials to create a new admin user.
+                      </p>
+                    </div>
+                  </div>
+                </div>
+
+                <div>
+                  <label htmlFor="admin-username" className="block text-sm font-medium text-white mb-2">
+                    Admin Username <span className="text-bambu-gray text-xs">(optional if admin users exist)</span>
+                  </label>
+                  <input
+                    id="admin-username"
+                    type="text"
+                    value={adminUsername}
+                    onChange={(e) => setAdminUsername(e.target.value)}
+                    className="block 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 admin username (optional)"
+                    autoComplete="username"
+                  />
+                </div>
+
+                <div>
+                  <label htmlFor="admin-password" className="block text-sm font-medium text-white mb-2">
+                    Admin Password <span className="text-bambu-gray text-xs">(optional if admin users exist)</span>
+                  </label>
+                  <input
+                    id="admin-password"
+                    type="password"
+                    value={adminPassword}
+                    onChange={(e) => setAdminPassword(e.target.value)}
+                    className="block 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 admin password (optional)"
+                    minLength={6}
+                    autoComplete="new-password"
+                  />
+                </div>
+
+                {adminPassword && (
+                  <div>
+                    <label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
+                      Confirm Password
+                    </label>
+                    <input
+                      id="confirm-password"
+                      type="password"
+                      value={confirmPassword}
+                      onChange={(e) => setConfirmPassword(e.target.value)}
+                      className="block 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="Confirm admin password"
+                      minLength={6}
+                      autoComplete="new-password"
+                    />
+                  </div>
+                )}
+              </div>
+            )}
+          </div>
+
+          <div>
+            <button
+              type="submit"
+              disabled={setupMutation.isPending}
+              className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
+            >
+              {setupMutation.isPending ? 'Setting up...' : 'Complete Setup'}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+}

+ 409 - 0
frontend/src/pages/UsersPage.tsx

@@ -0,0 +1,409 @@
+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, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
+import { api } from '../api/client';
+import type { UserCreate, UserUpdate } 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 UsersPage() {
+  const navigate = useNavigate();
+  const { user: currentUser } = useAuth();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const [showCreateModal, setShowCreateModal] = useState(false);
+  const [editingUser, setEditingUser] = useState<number | null>(null);
+  const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
+  const [formData, setFormData] = useState<UserCreate>({
+    username: '',
+    password: '',
+    role: 'user',
+  });
+
+  // Close modal on Escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape' && showCreateModal) {
+        setShowCreateModal(false);
+        setFormData({ username: '', password: '', role: 'user' });
+      }
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [showCreateModal]);
+
+  const { data: users = [], isLoading } = useQuery({
+    queryKey: ['users'],
+    queryFn: () => api.getUsers(),
+  });
+
+  const createMutation = useMutation({
+    mutationFn: (data: UserCreate) => api.createUser(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['users'] });
+      setShowCreateModal(false);
+      setFormData({ username: '', password: '', role: 'user' });
+      showToast('User created successfully');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['users'] });
+      setEditingUser(null);
+      setFormData({ username: '', password: '', role: 'user' });
+      showToast('User updated successfully');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => api.deleteUser(id),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['users'] });
+      showToast('User deleted successfully');
+    },
+    onError: (error: Error) => {
+      showToast(error.message, 'error');
+    },
+  });
+
+  const handleCreate = () => {
+    if (!formData.username || !formData.password) {
+      showToast('Please fill in all required fields', 'error');
+      return;
+    }
+    createMutation.mutate(formData);
+  };
+
+  const handleUpdate = (id: number) => {
+    const updateData: UserUpdate = {
+      username: formData.username || undefined,
+      password: formData.password || undefined,
+      role: formData.role,
+    };
+    // Remove password if empty
+    if (!updateData.password) {
+      delete updateData.password;
+    }
+    updateMutation.mutate({ id, data: updateData });
+  };
+
+  const handleDelete = (id: number) => {
+    setDeleteUserId(id);
+  };
+
+  const startEdit = (user: { id: number; username: string; role: string }) => {
+    setEditingUser(user.id);
+    setFormData({
+      username: user.username,
+      password: '',
+      role: user.role,
+    });
+  };
+
+  if (currentUser?.role !== 'admin') {
+    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>
+    );
+  }
+
+  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">
+              <UsersIcon className="w-6 h-6 text-bambu-green" />
+              User Management
+            </h1>
+            <p className="text-sm text-bambu-gray mt-1">
+              Manage users and their access to your Bambuddy instance
+            </p>
+          </div>
+        </div>
+        <Button
+          onClick={() => {
+            setShowCreateModal(true);
+            setFormData({ username: '', password: '', role: 'user' });
+          }}
+        >
+          <Plus className="w-4 h-4" />
+          Create User
+        </Button>
+      </div>
+
+      {isLoading ? (
+        <div className="flex items-center justify-center py-12">
+          <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+        </div>
+      ) : (
+        <Card>
+          <div className="overflow-x-auto">
+            <table className="min-w-full divide-y divide-bambu-dark-tertiary">
+              <thead>
+                <tr>
+                  <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
+                    Username
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
+                    Role
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
+                    Status
+                  </th>
+                  <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
+                    Actions
+                  </th>
+                </tr>
+              </thead>
+              <tbody className="divide-y divide-bambu-dark-tertiary">
+                {users.map((user) => (
+                  <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">
+                      {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
+                      )}
+                    </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>
+                    <td className="px-6 py-4 whitespace-nowrap text-sm">
+                      <span className={`px-3 py-1 rounded-full text-xs font-medium ${
+                        user.is_active
+                          ? 'bg-bambu-green/20 text-bambu-green'
+                          : 'bg-red-500/20 text-red-400'
+                      }`}>
+                        {user.is_active ? 'Active' : 'Inactive'}
+                      </span>
+                    </td>
+                    <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">
+                          <Button
+                            size="sm"
+                            variant="ghost"
+                            onClick={() => startEdit(user)}
+                          >
+                            <Edit2 className="w-4 h-4" />
+                            Edit
+                          </Button>
+                          {user.id !== currentUser?.id && (
+                            <Button
+                              size="sm"
+                              variant="ghost"
+                              onClick={() => handleDelete(user.id)}
+                            >
+                              <Trash2 className="w-4 h-4" />
+                              Delete
+                            </Button>
+                          )}
+                        </div>
+                      )}
+                    </td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </div>
+        </Card>
+      )}
+
+      {/* Create User Modal */}
+      {showCreateModal && (
+        <div
+          className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+          onClick={() => {
+            setShowCreateModal(false);
+            setFormData({ username: '', password: '', role: 'user' });
+          }}
+        >
+          <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">
+                  <UsersIcon className="w-5 h-5 text-bambu-green" />
+                  <h2 className="text-lg font-semibold text-white">Create User</h2>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => {
+                    setShowCreateModal(false);
+                    setFormData({ username: '', password: '', role: 'user' });
+                  }}
+                >
+                  <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
+                  </label>
+                  <input
+                    type="password"
+                    value={formData.password}
+                    onChange={(e) => setFormData({ ...formData, password: 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 password"
+                    autoComplete="new-password"
+                    minLength={6}
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Role
+                  </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>
+                </div>
+              </div>
+              <div className="mt-6 flex justify-end gap-3">
+                <Button
+                  variant="secondary"
+                  onClick={() => {
+                    setShowCreateModal(false);
+                    setFormData({ username: '', password: '', role: 'user' });
+                  }}
+                >
+                  Cancel
+                </Button>
+                <Button
+                  onClick={handleCreate}
+                  disabled={createMutation.isPending || !formData.username || !formData.password}
+                >
+                  {createMutation.isPending ? (
+                    <>
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      Creating...
+                    </>
+                  ) : (
+                    <>
+                      <Plus className="w-4 h-4" />
+                      Create User
+                    </>
+                  )}
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+
+      {/* Delete Confirmation Modal */}
+      {deleteUserId !== null && (
+        <ConfirmModal
+          title="Delete User"
+          message={`Are you sure you want to delete this user? This action cannot be undone.`}
+          confirmText="Delete User"
+          variant="danger"
+          onConfirm={() => {
+            deleteMutation.mutate(deleteUserId);
+            setDeleteUserId(null);
+          }}
+          onCancel={() => setDeleteUserId(null)}
+        />
+      )}
+    </div>
+  );
+}

+ 1 - 0
frontend/tailwind.config.js

@@ -16,6 +16,7 @@ export default {
           dark: '#1a1a1a',
           'dark-secondary': '#2d2d2d',
           'dark-tertiary': '#3d3d3d',
+          card: '#2d2d2d', // Same as dark-secondary for card backgrounds
           gray: '#808080',
           'gray-light': '#a0a0a0',
           'gray-dark': '#4a4a4a',

+ 4 - 0
requirements.txt

@@ -37,6 +37,10 @@ qrcode[pil]>=7.4.0
 # System monitoring
 psutil>=6.0.0
 
+# Authentication
+python-jose[cryptography]>=3.3.0
+passlib[bcrypt]>=1.7.4
+
 # Development
 pytest>=8.0.0
 pytest-asyncio>=0.23.0

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BmODu1qm.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CPB3KMs0.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CVDQtTMh.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DFo1_Rau.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DFo1_Rau.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BmODu1qm.css">
+    <script type="module" crossorigin src="/assets/index-CPB3KMs0.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CVDQtTMh.css">
   </head>
   <body>
     <div id="root"></div>

+ 4 - 4
test_docker.sh

@@ -121,7 +121,7 @@ if [ "$RUN_BUILD" = true ]; then
     print_header "Test 1: Docker Build"
     print_info "Building production Docker image..."
 
-    if sudo docker build -t bambuddy:test . --quiet --pull; then
+    if sudo docker build -t bambuddy:test . --pull --no-cache --progress=plain; then
         print_success "Production image builds successfully"
 
         # Verify image has expected labels/structure
@@ -149,7 +149,7 @@ if [ "$RUN_BACKEND" = true ]; then
     print_header "Test 2: Backend Unit Tests"
     print_info "Building backend test image..."
 
-    if sudo docker compose -f docker-compose.test.yml build backend-test --quiet --pull; then
+    if sudo docker compose -f docker-compose.test.yml build backend-test --pull --no-cache --progress=plain; then
         print_info "Running backend tests..."
         if sudo docker compose -f docker-compose.test.yml run --rm backend-test; then
             print_success "Backend unit tests passed"
@@ -168,7 +168,7 @@ if [ "$RUN_FRONTEND" = true ]; then
     print_header "Test 3: Frontend Unit Tests"
     print_info "Building frontend test image..."
 
-    if sudo docker compose -f docker-compose.test.yml build frontend-test --quiet --pull; then
+    if sudo docker compose -f docker-compose.test.yml build frontend-test --pull --no-cache --progress=plain; then
         print_info "Running frontend tests..."
         if sudo docker compose -f docker-compose.test.yml run --rm frontend-test; then
             print_success "Frontend unit tests passed"
@@ -188,7 +188,7 @@ if [ "$RUN_INTEGRATION" = true ]; then
     print_info "Building integration container..."
 
     # Build the integration container first to ensure latest code
-    if ! sudo docker compose -f docker-compose.test.yml build integration --quiet --pull; then
+    if ! sudo docker compose -f docker-compose.test.yml build integration --pull --no-cache --progress=plain; then
         print_failure "Integration container build failed"
     else
         print_info "Starting application container..."

Некоторые файлы не были показаны из-за большого количества измененных файлов