Browse Source

Merge branch '0.1.8b' into feature/updated_plate_view

MartinNYHC 3 months ago
parent
commit
685bf84de1
100 changed files with 11111 additions and 1879 deletions
  1. 5 2
      .gitignore
  2. 125 1
      CHANGELOG.md
  3. 0 247
      PLAN.md
  4. 46 5
      README.md
  5. 0 318
      RELEASE_NOTES_0.1.6.md
  6. 5 0
      backend/app/api/routes/ams_history.py
  7. 11 2
      backend/app/api/routes/api_keys.py
  8. 141 27
      backend/app/api/routes/archives.py
  9. 28 3
      backend/app/api/routes/camera.py
  10. 84 19
      backend/app/api/routes/cloud.py
  11. 29 8
      backend/app/api/routes/discovery.py
  12. 18 2
      backend/app/api/routes/external_links.py
  13. 25 4
      backend/app/api/routes/filaments.py
  14. 14 2
      backend/app/api/routes/firmware.py
  15. 28 5
      backend/app/api/routes/github_backup.py
  16. 10 0
      backend/app/api/routes/kprofiles.py
  17. 88 17
      backend/app/api/routes/library.py
  18. 25 3
      backend/app/api/routes/maintenance.py
  19. 4 2
      backend/app/api/routes/metrics.py
  20. 25 5
      backend/app/api/routes/notification_templates.py
  21. 20 2
      backend/app/api/routes/notifications.py
  22. 22 4
      backend/app/api/routes/pending_uploads.py
  23. 13 3
      backend/app/api/routes/print_queue.py
  24. 6 3
      backend/app/api/routes/printers.py
  25. 27 0
      backend/app/api/routes/projects.py
  26. 236 94
      backend/app/api/routes/settings.py
  27. 94 57
      backend/app/api/routes/smart_plugs.py
  28. 71 8
      backend/app/api/routes/spoolman.py
  29. 19 6
      backend/app/api/routes/support.py
  30. 7 1
      backend/app/api/routes/system.py
  31. 18 4
      backend/app/api/routes/updates.py
  32. 67 1
      backend/app/core/auth.py
  33. 1 1
      backend/app/core/config.py
  34. 6 0
      backend/app/core/database.py
  35. 165 2
      backend/app/main.py
  36. 3 0
      backend/app/models/print_queue.py
  37. 5 2
      backend/app/schemas/cloud.py
  38. 3 0
      backend/app/schemas/print_queue.py
  39. 4 2
      backend/app/services/archive.py
  40. 110 8
      backend/app/services/bambu_cloud.py
  41. 2 1
      backend/app/services/bambu_ftp.py
  42. 18 1
      backend/app/services/bambu_mqtt.py
  43. 21 7
      backend/app/services/print_scheduler.py
  44. 1 0
      backend/app/services/printer_manager.py
  45. 125 15
      backend/app/services/virtual_printer/manager.py
  46. 425 0
      backend/app/services/virtual_printer/tcp_proxy.py
  47. 2 1
      backend/tests/conftest.py
  48. 85 0
      backend/tests/integration/test_auth_api.py
  49. 173 0
      backend/tests/integration/test_endpoint_auth.py
  50. 207 0
      backend/tests/integration/test_library_api.py
  51. 8 2
      backend/tests/integration/test_ownership_permissions.py
  52. 213 0
      backend/tests/integration/test_print_queue_api.py
  53. 77 0
      backend/tests/integration/test_spoolman_api.py
  54. 238 0
      backend/tests/unit/services/test_bambu_cloud.py
  55. 104 0
      backend/tests/unit/services/test_virtual_printer.py
  56. 12 28
      docker-publish.sh
  57. BIN
      docs/images/proxy-mode-diagram.png
  58. 401 0
      frontend/docs/create_proxy_diagram.py
  59. BIN
      frontend/docs/proxy-mode-diagram.png
  60. 185 0
      frontend/src/__tests__/api/client.test.ts
  61. 1 1
      frontend/src/__tests__/components/ConfirmModal.test.tsx
  62. 291 0
      frontend/src/__tests__/components/LinkSpoolModal.test.tsx
  63. 24 43
      frontend/src/__tests__/components/SpoolmanSettings.test.tsx
  64. 99 2
      frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx
  65. 95 0
      frontend/src/__tests__/contexts/AuthContext.test.tsx
  66. 35 0
      frontend/src/__tests__/i18n/locales.test.ts
  67. 18 12
      frontend/src/__tests__/pages/CameraPage.test.tsx
  68. 220 10
      frontend/src/api/client.ts
  69. 43 6
      frontend/src/components/AddSmartPlugModal.tsx
  70. 10 5
      frontend/src/components/ConfirmModal.tsx
  71. 50 50
      frontend/src/components/EditArchiveModal.tsx
  72. 7 10
      frontend/src/components/EmbeddedCameraViewer.tsx
  73. 34 14
      frontend/src/components/FilamentHoverCard.tsx
  74. 16 12
      frontend/src/components/FilamentTrends.tsx
  75. 17 13
      frontend/src/components/FileManagerModal.tsx
  76. 7 2
      frontend/src/components/GitHubBackupSettings.tsx
  77. 7 4
      frontend/src/components/HMSErrorModal.tsx
  78. 97 95
      frontend/src/components/KProfilesView.tsx
  79. 20 21
      frontend/src/components/Layout.tsx
  80. 16 8
      frontend/src/components/LinkSpoolModal.tsx
  81. 18 16
      frontend/src/components/MQTTDebugModal.tsx
  82. 69 5
      frontend/src/components/PrintModal/PrinterSelector.tsx
  83. 150 6
      frontend/src/components/PrintModal/ScheduleOptions.tsx
  84. 33 13
      frontend/src/components/PrintModal/index.tsx
  85. 8 0
      frontend/src/components/PrintModal/types.ts
  86. 5 0
      frontend/src/components/SmartPlugCard.tsx
  87. 2 28
      frontend/src/components/SpoolmanSettings.tsx
  88. 12 10
      frontend/src/components/UploadModal.tsx
  89. 267 116
      frontend/src/components/VirtualPrinterSettings.tsx
  90. 20 2
      frontend/src/contexts/ToastContext.tsx
  91. 4 1
      frontend/src/i18n/index.ts
  92. 1018 20
      frontend/src/i18n/locales/de.ts
  93. 1017 19
      frontend/src/i18n/locales/en.ts
  94. 3046 0
      frontend/src/i18n/locales/ja.ts
  95. 200 198
      frontend/src/pages/ArchivesPage.tsx
  96. 29 30
      frontend/src/pages/CameraPage.tsx
  97. 3 1
      frontend/src/pages/ExternalLinkPage.tsx
  98. 146 133
      frontend/src/pages/FileManagerPage.tsx
  99. 31 29
      frontend/src/pages/GroupsPage.tsx
  100. 21 19
      frontend/src/pages/LoginPage.tsx

+ 5 - 2
.gitignore

@@ -35,8 +35,8 @@ archive/
 # Firmware cache (downloaded firmware files)
 firmware/
 
-# Virtual printer (auto-generated certs and uploads)
-virtual_printer/
+# Virtual printer (auto-generated certs and uploads at repo root)
+/virtual_printer/
 
 # IDE
 .idea/
@@ -58,3 +58,6 @@ firmware/
 node_modules/
 
 data/
+
+# JWT secret file (should be in data dir, but protect project root too)
+.jwt_secret

+ 125 - 1
CHANGELOG.md

@@ -2,9 +2,116 @@
 
 All notable changes to Bambuddy will be documented in this file.
 
-## [0.1.7b] - Not released
+
+## [0.1.8b] - Not released
+
+### Added
+- **Windows Portable Launcher** (contributed by nmori):
+  - New `start_bambuddy.bat` for Windows users - double-click to run, no installation required
+  - Automatically downloads Python 3.13 and Node.js 22 on first run (portable, no system changes)
+  - Everything stored in `.portable\` folder for easy cleanup
+  - Commands: `start_bambuddy.bat` (launch), `start_bambuddy.bat update` (update deps), `start_bambuddy.bat reset` (clean start)
+  - Custom port via `set PORT=9000 & start_bambuddy.bat`
+  - Verifies all downloads with SHA256 checksums for security
+  - Supports both x64 and ARM64 Windows systems
+
+## [0.1.7] - 2026-02-03
+
+### Security
+- **Critical: Missing API Endpoint Authentication** (CVE-2026-25505, CVSS 9.8):
+  - Added authentication to 200+ API endpoints that were previously unprotected
+  - All route files now use `RequirePermissionIfAuthEnabled()` for permission checks
+  - Protected endpoints: archives, projects, settings, API keys, groups, cloud, notifications, maintenance, filaments, external links, smart plugs, discovery, firmware, camera, k-profiles, AMS history, pending uploads, updates, spoolman, system, print queue, printers
+  - Image-serving endpoints (thumbnails, timelapse, photos, camera streams) remain public as they require knowing the resource ID and are loaded via `<img>` tags which cannot send Authorization headers
+  - Backend integration tests added to verify endpoint authentication enforcement
+
+### Enhancements
+- **TOTP Authenticator Support for Bambu Cloud** (Issue #182):
+  - Added support for TOTP-based two-factor authentication when connecting to Bambu Cloud
+  - Accounts with authenticator apps (Google Authenticator, Authy, etc.) now work correctly
+  - Proper detection of verification type: email code vs TOTP code
+  - Uses browser-like headers to bypass Cloudflare protection on TFA endpoint
+  - Frontend shows appropriate message for each verification type
+  - Added translations for TOTP UI in English, German, and Japanese
+- **Spoolman: Open in Spoolman Button** (Issue #210):
+  - FilamentHoverCard now shows "Open in Spoolman" button when spool is already linked in Spoolman
+  - Button links directly to the spool's page in Spoolman for quick editing
+  - "Link to Spoolman" button now only shows when spool is not yet linked
+  - Link button correctly disabled when no unlinked spools are available in Spoolman
+  - Toast notification shown on successful/failed spool linking
+  - Added `/api/v1/spoolman/spools/linked` endpoint returning map of linked spool tags to IDs
+- **Complete German Translations**:
+  - All UI strings now fully translated to German (1800+ translation keys)
+  - Pages translated: Settings, Archives, File Manager, Queue, Printers, Profiles, Projects, Stats, Maintenance, Camera, Groups, Users, Login, Setup, Stream Overlay
+  - Components translated: ConfirmModal, LinkSpoolModal, FilamentHoverCard, Layout
+  - Added locale parity test to ensure English and German stay in sync
+- **Virtual Printer Proxy Mode**:
+  - New "Proxy" mode allows remote printing over any network by relaying slicer traffic to a real printer
+  - Configure a target printer and Bambuddy acts as a TLS proxy between your slicer and the printer
+  - Supports both FTP (port 9990) and MQTT (port 8883) protocols with full TLS encryption
+  - Slicer connects to Bambuddy using the real printer's access code
+  - Real-time status display showing active FTP/MQTT connections
+  - Target printer selector with validation (must be configured in Bambuddy)
+  - Proxy mode bypasses the access code requirement (uses the real printer's credentials)
+  - Full i18n support for all proxy mode UI strings (English, German, Japanese)
+
+### Fixed
+- **Cannot Link Multiple HA Entities to Same Printer** (Issue #214):
+  - Fixed Home Assistant entities being limited to one per printer
+  - Both frontend and backend were blocking printers that already had any smart plug linked
+  - Now only Tasmota plugs are limited to one per printer (physical device constraint)
+  - Multiple HA entities (switches, scripts, lights, etc.) can be linked to the same printer
+  - Restored "Show on Printer Card" toggle for HA entities to control visibility on printer cards
+  - Fixed printer card only showing `script.*` entities; now shows all HA entities with toggle enabled
+  - HA entities now default to auto_on=False and auto_off=False (appropriate for automations)
+  - Printer cards now update immediately when HA entities are added/modified/deleted
+- **Monthly Comparison Calculation Off** (Issue #229):
+  - Fixed filament statistics not accounting for quantity multiplier
+  - Monthly comparison chart now correctly multiplies `filament_used_grams` by `quantity`
+  - Daily and weekly charts also now account for quantity
+  - Filament type breakdown includes quantity in calculations
+  - Backend stats endpoint (`/archives/stats`) and Prometheus metrics also fixed
+  - Prints count now shows total items (sum of quantities) instead of archive count
+- **Authentication Required for Downloads** (Issue #231):
+  - Fixed support bundle download returning 401 Unauthorized when auth is enabled
+  - Fixed archive export (CSV/XLSX) failing with authentication enabled
+  - Fixed statistics export failing with authentication enabled
+  - Fixed printer file ZIP download failing with authentication enabled
+  - Root cause: These endpoints used raw `fetch()` without Authorization header
+- **Queue Schedule Date Picker Ignores User Format Settings** (Issue #233):
+  - Replaced native datetime picker with custom date/time inputs respecting user settings
+  - Date input shows in user's format (DD/MM/YYYY for EU, MM/DD/YYYY for US, YYYY-MM-DD for ISO)
+  - Time input shows in user's format (24H or 12H with AM/PM)
+  - Calendar button opens native picker for convenience; selection is formatted to user's preference
+  - Placeholder text shows expected format (e.g., "DD/MM/YYYY" or "HH:MM AM/PM")
+  - Added date utilities: `formatDateInput`, `parseDateInput`, `getDatePlaceholder`
+  - Added time utilities: `formatTimeInput`, `parseTimeInput`, `getTimePlaceholder`
+- **500 Error on Archive Detail Page**:
+  - Fixed internal server error when viewing individual archive details
+  - Root cause: `project` relationship not eagerly loaded in `get_archive()` service method
+  - Async SQLAlchemy requires explicit eager loading; lazy loading is not supported
+
+## [0.1.6.2] - 2026-02-02
+
+> **Security Release**: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately.
+
+### Security
+- **Critical: Hardcoded JWT Secret Key** (GHSA-gc24-px2r-5qmf, CWE-321) - Fixed hardcoded JWT secret key that could allow attackers to forge authentication tokens:
+  - JWT secret now loaded from `JWT_SECRET_KEY` environment variable (recommended for production)
+  - Falls back to auto-generated `.jwt_secret` file in data directory with secure permissions (0600)
+  - Generates cryptographically secure 64-byte random secret if neither exists
+  - **Action Required**: Existing users will need to re-login after upgrading
+- **Critical: Missing API Authentication** (GHSA-gc24-px2r-5qmf, CWE-306) - Fixed 77+ API endpoints that lacked authentication checks:
+  - Added HTTP middleware enforcing authentication on ALL `/api/` routes when auth is enabled
+  - Only essential public endpoints are exempt (login, auth status, version check, WebSocket)
+  - All other API calls now require valid JWT token or API key
 
 ### Enhancements
+- **Location Filter for Queue** (Issue #220):
+  - Filter queue jobs by printer location in the Queue page
+  - "Any {Model}" queue assignments can now specify a target location (e.g., "Any X1C in Workshop")
+  - Location filter dropdown shows all unique locations from printers and queue items
+  - Location is saved with queue items and displayed in the queue list
 - **Ownership-Based Permissions** (Issue #205):
   - Users can now only update/delete their own items unless they have elevated permissions
   - Update/delete permissions split into `*_own` and `*_all` variants:
@@ -51,11 +158,28 @@ All notable changes to Bambuddy will be documented in this file.
   - Removed ~2000 lines of legacy JSON-based backup/restore code
 
 ### Fixes
+- **File Manager permissions not enforced** (Issue #224) - Fixed backend not checking `library:read` permission for File Manager endpoints:
+  - Added `library:read` permission check to all list/view endpoints (files, folders, stats)
+  - Added `library:upload` permission check to upload and folder creation endpoints
+  - Added `queue:create` permission check to add-to-queue endpoint
+  - Added `printers:control` permission check to direct print endpoint
+  - Added ownership-based permission checks to file move operation
+  - Users without `library:read` permission can no longer view files in the File Manager
+  - Users can now only delete/update their own files unless they have `*_all` permissions
+- **JWT secret key not persistent across restarts** - Fixed JWT secret key generation to properly use data directory, ensuring tokens remain valid across container restarts
+- **Images/thumbnails returning 401 when auth enabled** - Fixed auth middleware to allow public access to image/media endpoints (thumbnails, photos, QR codes, timelapses, camera streams) since browser elements like `<img>` don't send Authorization headers
 - **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems:
   - Library now stores relative paths in database for portability
   - Automatic migration converts existing absolute paths to relative on startup
   - Thumbnails and files now display correctly after restoring backups
 - **File uploads failing with authentication enabled** - Fixed all file upload functions (archives, photos, timelapses, library files, etc.) not sending authentication headers when auth is enabled
+- **External spool AMS mapping causing "Failed to get AMS mapping table"** (Issue #213) - Fixed external spool `ams_mapping2` slot_id handling that caused AMS mapping failures
+- **Filename matching for files with spaces** (Issue #218) - Fixed file detection when filenames contain spaces
+- **P2S FTP upload failure** (Issue #218) - Fixed FTP uploads to P2S printers by passing `skip_session_reuse` to ImplicitFTP_TLS
+- **Printer deletion freeze** (Issue #214) - Fixed UI freeze when deleting printers, and now allows multiple smart plugs per printer
+- **Stack trace exposure in error responses** (CodeQL Alert #68) - Fixed stack traces being exposed in API error responses in archives.py
+- **Printer serial numbers exposed in support bundle** (Issue #216) - Sanitized printer serial numbers in support bundle logs for privacy
+- **Missing sliced_for_model migration** (Issue #211) - Fixed database migration for `sliced_for_model` column that was missing in some upgrade paths
 
 ## [0.1.6-final] - 2026-01-31
 

+ 0 - 247
PLAN.md

@@ -1,247 +0,0 @@
-# Notification Templates Management System
-
-## Overview
-
-Replace hardcoded notification messages with a flexible template system that allows users to customize notification content per event type, with provider-specific formatting support.
-
----
-
-## Data Model
-
-### New Table: `notification_templates`
-
-```sql
-CREATE TABLE notification_templates (
-    id INTEGER PRIMARY KEY,
-    event_type VARCHAR(50) NOT NULL,  -- print_start, print_complete, etc.
-    name VARCHAR(100) NOT NULL,       -- User-friendly name
-    title_template TEXT NOT NULL,     -- Template for notification title
-    body_template TEXT NOT NULL,      -- Template for notification body
-    is_default BOOLEAN DEFAULT 0,     -- System default (non-deletable)
-    created_at DATETIME,
-    updated_at DATETIME
-);
-```
-
-**Event Types:**
-- `print_start`
-- `print_complete`
-- `print_failed`
-- `print_stopped`
-- `print_progress`
-- `printer_offline`
-- `printer_error`
-- `filament_low`
-- `maintenance_due`
-- `test` (for test notifications)
-
----
-
-## Template Variables
-
-Variables use `{variable_name}` syntax (Python format strings).
-
-### Per-Event Variables:
-
-| Event | Variables |
-|-------|-----------|
-| `print_start` | `{printer}`, `{filename}`, `{estimated_time}` |
-| `print_complete` | `{printer}`, `{filename}`, `{duration}`, `{filament_grams}` |
-| `print_failed` | `{printer}`, `{filename}`, `{duration}`, `{reason}` |
-| `print_stopped` | `{printer}`, `{filename}`, `{duration}` |
-| `print_progress` | `{printer}`, `{filename}`, `{progress}`, `{remaining_time}` |
-| `printer_offline` | `{printer}` |
-| `printer_error` | `{printer}`, `{error_type}`, `{error_detail}` |
-| `filament_low` | `{printer}`, `{slot}`, `{remaining_percent}`, `{color}` |
-| `maintenance_due` | `{printer}`, `{items}` (formatted list) |
-| `test` | `{app_name}` |
-
-### Common Variables (all events):
-- `{timestamp}` - Current date/time
-- `{app_name}` - "Bambuddy"
-
----
-
-## Default Templates
-
-Pre-seeded templates for each event (marked `is_default=True`):
-
-```
-print_start:
-  title: "Print Started"
-  body: "{printer}: {filename}\nEstimated: {estimated_time}"
-
-print_complete:
-  title: "Print Completed"
-  body: "{printer}: {filename}\nTime: {duration}\nFilament: {filament_grams}g"
-
-print_failed:
-  title: "Print Failed"
-  body: "{printer}: {filename}\nTime: {duration}\nReason: {reason}"
-
-print_stopped:
-  title: "Print Stopped"
-  body: "{printer}: {filename}\nTime: {duration}"
-
-print_progress:
-  title: "Print {progress}% Complete"
-  body: "{printer}: {filename}\nRemaining: {remaining_time}"
-
-printer_offline:
-  title: "Printer Offline"
-  body: "{printer} has disconnected"
-
-printer_error:
-  title: "Printer Error: {error_type}"
-  body: "{printer}\n{error_detail}"
-
-filament_low:
-  title: "Filament Low"
-  body: "{printer}: Slot {slot} at {remaining_percent}%"
-
-maintenance_due:
-  title: "Maintenance Due"
-  body: "{printer}:\n{items}"
-
-test:
-  title: "Bambuddy Test"
-  body: "This is a test notification. If you see this, notifications are working!"
-```
-
----
-
-## Provider-Specific Formatting
-
-The template system supports provider-specific formatting via a simple approach:
-
-1. **Plain text** (default) - Used for CallMeBot, ntfy, Pushover, Email
-2. **Markdown** - Automatically applied for Telegram (wrap title in `*bold*`)
-
-The notification service will:
-- Render the template with variables
-- Apply provider-specific formatting when sending
-
----
-
-## Implementation Steps
-
-### Backend
-
-1. **Create model** `backend/app/models/notification_template.py`
-   - NotificationTemplate SQLAlchemy model
-
-2. **Create schemas** `backend/app/schemas/notification_template.py`
-   - NotificationTemplateCreate, Update, Response
-   - TemplateVariables (documentation of available vars per event)
-
-3. **Add migration** in `backend/app/core/database.py`
-   - Create table if not exists
-   - Seed default templates
-
-4. **Create API routes** `backend/app/api/routes/notification_templates.py`
-   - `GET /api/v1/notification-templates` - List all templates
-   - `GET /api/v1/notification-templates/{id}` - Get single template
-   - `PUT /api/v1/notification-templates/{id}` - Update template
-   - `POST /api/v1/notification-templates/{id}/reset` - Reset to default
-   - `GET /api/v1/notification-templates/variables` - List available variables per event
-   - `POST /api/v1/notification-templates/preview` - Preview template with sample data
-
-5. **Update notification service** `backend/app/services/notification_service.py`
-   - Load templates from database
-   - Render templates with variables
-   - Remove hardcoded message builders
-
-6. **Register routes** in `backend/app/main.py`
-
-### Frontend
-
-7. **Add API client methods** `frontend/src/api/client.ts`
-   - getNotificationTemplates, updateNotificationTemplate, etc.
-
-8. **Create template editor component** `frontend/src/components/NotificationTemplateEditor.tsx`
-   - Template editing UI with variable insertion buttons
-   - Live preview with sample data
-   - Reset to default button
-
-9. **Update SettingsPage** `frontend/src/pages/SettingsPage.tsx`
-   - Add "Templates" sub-section in Notifications tab
-   - List all templates with edit capability
-
----
-
-## UI Design
-
-### Templates Section (in Settings > Notifications)
-
-```
-+--------------------------------------------------+
-| Message Templates                                |
-| Customize notification messages for each event   |
-+--------------------------------------------------+
-|                                                  |
-| +----------------+  +----------------+           |
-| | Print Started  |  | Print Complete |  ...     |
-| | "Print Started"|  | "Print Compl..." |        |
-| | [Edit]         |  | [Edit]         |          |
-| +----------------+  +----------------+           |
-|                                                  |
-+--------------------------------------------------+
-```
-
-### Template Editor Modal
-
-```
-+--------------------------------------------------+
-| Edit Template: Print Complete              [X]   |
-+--------------------------------------------------+
-| Title:                                           |
-| [Print Completed_________________________]       |
-|                                                  |
-| Body:                                            |
-| +----------------------------------------------+ |
-| | {printer}: {filename}                        | |
-| | Time: {duration}                             | |
-| | Filament: {filament_grams}g                  | |
-| +----------------------------------------------+ |
-|                                                  |
-| Available Variables:                             |
-| [+printer] [+filename] [+duration] [+filament]   |
-|                                                  |
-| Preview:                                         |
-| +----------------------------------------------+ |
-| | Title: Print Completed                       | |
-| | Body:  Bambu X1C: Benchy.3mf                 | |
-| |        Time: 1h 23m                          | |
-| |        Filament: 15.2g                       | |
-| +----------------------------------------------+ |
-|                                                  |
-| [Reset to Default]              [Cancel] [Save]  |
-+--------------------------------------------------+
-```
-
----
-
-## File Changes Summary
-
-| File | Action |
-|------|--------|
-| `backend/app/models/notification_template.py` | Create |
-| `backend/app/schemas/notification_template.py` | Create |
-| `backend/app/api/routes/notification_templates.py` | Create |
-| `backend/app/core/database.py` | Modify (add migration + seeding) |
-| `backend/app/models/__init__.py` | Modify (export new model) |
-| `backend/app/services/notification_service.py` | Modify (use templates) |
-| `backend/app/main.py` | Modify (register routes) |
-| `frontend/src/api/client.ts` | Modify (add API methods + types) |
-| `frontend/src/components/NotificationTemplateEditor.tsx` | Create |
-| `frontend/src/pages/SettingsPage.tsx` | Modify (add templates section) |
-
----
-
-## Notes
-
-- Default templates cannot be deleted, only modified and reset
-- Templates are language-agnostic (user writes in their preferred language)
-- The existing `notification_language` setting can be removed later (templates replace i18n)
-- Variables that are unavailable for an event will render as empty string
-- Template rendering uses safe formatting (missing vars don't crash)

+ 46 - 5
README.md

@@ -10,6 +10,7 @@
 
 <p align="center">
   <a href="https://github.com/maziggy/bambuddy/releases"><img src="https://img.shields.io/github/v/release/maziggy/bambuddy?style=flat-square&color=blue" alt="Release"></a>
+  <img src="https://github.com/maziggy/bambuddy/actions/workflows/ci.yml/badge.svg?branch=main">
   <a href="https://github.com/maziggy/bambuddy/blob/main/LICENSE"><img src="https://img.shields.io/github/license/maziggy/bambuddy?style=flat-square" alt="License"></a>
   <a href="https://github.com/maziggy/bambuddy/stargazers"><img src="https://img.shields.io/github/stars/maziggy/bambuddy?style=flat-square" alt="Stars"></a>
   <a href="https://github.com/maziggy/bambuddy/issues"><img src="https://img.shields.io/github/issues/maziggy/bambuddy?style=flat-square" alt="Issues"></a>
@@ -28,6 +29,25 @@
 
 ---
 
+## 🌐 NEW: Remote Printing with Proxy Mode
+
+<p align="center">
+  <img src="docs/images/proxy-mode-diagram.png" alt="Proxy Mode Architecture" width="800">
+</p>
+
+**Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
+
+- 🔒 **End-to-end TLS encryption** — Your print data is encrypted from slicer to printer
+- 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
+- 🔑 **Uses printer's access code** — No additional credentials needed
+- ⚡ **Full-speed printing** — FTP and MQTT protocols proxied transparently
+
+Perfect for remote print farms, traveling makers, or accessing your home printer from work.
+
+👉 **[Setup Guide →](https://wiki.bambuddy.cool/features/virtual-printer/#proxy-mode-new-in-017)**
+
+---
+
 > **Testers Needed!** I only have X1C and H2D devices. Help make Bambuddy work with all Bambu Lab printers by [reporting your experience](https://github.com/maziggy/bambuddy/issues)!
 
 ## Why Bambuddy?
@@ -77,7 +97,7 @@
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
-- Model-based queue assignment (send to "any X1C" for load balancing)
+- Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Filament validation (only assign to printers with required filaments)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
@@ -135,13 +155,14 @@
 - Webhooks & API keys
 - Interactive API browser with live testing
 
-### 🖨️ Virtual Printer
+### 🖨️ Virtual Printer & Remote Printing
+- **🌐 Proxy Mode (NEW!)** — Print remotely from anywhere via secure TLS relay
 - Emulates a Bambu Lab printer on your network
 - Send prints directly from Bambu Studio/Orca Slicer
 - Configurable printer model (X1C, P1S, A1, H2D, etc.)
-- Queue mode or auto-start mode
+- Archive mode, Review mode, Queue mode, or Proxy mode
 - SSDP discovery (appears in slicer automatically)
-- Secure TLS/MQTT communication
+- Secure TLS/MQTT/FTP communication
 
 ### 🛠️ Maintenance & Support
 - Maintenance scheduling & tracking
@@ -158,6 +179,7 @@
 - Group-based permissions (50+ granular permissions)
 - Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
+- Comprehensive API protection (200+ endpoints secured)
 - User management (create, edit, delete, groups)
 - User activity tracking (who uploaded archives, library files, queued prints, started prints)
 
@@ -421,7 +443,26 @@ services:
 
 </details>
 
-#### Manual Installation
+#### Windows (Portable Launcher)
+
+The easiest way to run Bambuddy on Windows - no installation required:
+
+```batch
+git clone https://github.com/maziggy/bambuddy.git
+cd bambuddy
+start_bambuddy.bat
+```
+
+Double-click `start_bambuddy.bat` and it will:
+- Download Python and Node.js automatically (portable, no system changes)
+- Install dependencies and build the frontend
+- Open your browser to http://localhost:8000
+
+Everything is stored in the `.portable\` folder. Use `start_bambuddy.bat reset` to clean up.
+
+> **Custom port:** `set PORT=9000 & start_bambuddy.bat`
+
+#### Manual Installation (Linux/macOS)
 
 ```bash
 # Clone and setup

+ 0 - 318
RELEASE_NOTES_0.1.6.md

@@ -1,318 +0,0 @@
-# Bambuddy v0.1.6 - Final Release
-
-**Release Date:** January 31, 2026
-
-After 11 beta releases and extensive community testing, we're excited to announce **Bambuddy 0.1.6-final** - our biggest release yet! This release brings optional authentication, build plate detection, external camera support, model-based queue scheduling, and much more.
-
----
-
-## Highlights
-
-### Optional Authentication & User Management
-Secure your Bambuddy instance for the first time:
-- Enable/disable authentication via Settings
-- **Role-based access**: Admin (full access) and User (prints only) roles
-- **Group-based permissions**: 50+ granular permissions with custom groups
-- JWT-based authentication with user management UI
-- Users can change their own password from the sidebar
-
-### Build Plate Empty Detection
-Never start a print with objects on the bed again:
-- Per-printer toggle for plate detection
-- Multi-reference calibration (up to 5 plate types)
-- Automatic print pause when objects detected
-- Push notifications and WebSocket alerts
-- ROI calibration for precise detection area
-
-### External & USB Camera Support
-Use any camera with your printers:
-- **External cameras**: MJPEG, RTSP, HTTP snapshot support
-- **USB cameras**: V4L2 webcam support on Linux
-- Layer-based timelapse with external cameras
-- Finish photo capture from external sources
-
-### Model-Based Queue Assignment
-Perfect for print farms:
-- Queue items to "Any X1C", "Any P1S", etc.
-- Auto-assigns to available printer when ready
-- Automatic filament validation and AMS mapping
-- Matches required filaments to loaded spools
-
-### GitHub Profile Backup
-Automated backup of your settings to GitHub:
-- Schedule hourly, daily, or weekly backups
-- Backs up K-profiles, cloud profiles, and app settings
-- Skip unchanged commits (only commit when data changes)
-- Backup history log with commit links
-
-### Prometheus Metrics
-Export printer telemetry for external monitoring:
-- Endpoint: `GET /api/v1/metrics`
-- Printer temps, fans, WiFi, print progress
-- Ready for Grafana dashboards
-- Optional bearer token authentication
-
----
-
-## New Features
-
-### Authentication & Security
-- **Optional Authentication** - Secure your Bambuddy instance with JWT-based user authentication
-- **Group-Based Permissions** - 50+ granular permissions with custom groups (Administrators, Operators, Viewers)
-- **Change Password** - Users can update their own password from sidebar
-- **API Keys** - API key authentication with granular permissions
-
-### Virtual Printer
-- **Virtual Printer** - Emulates a Bambu Lab printer on your network for Bambu Studio/Orca Slicer
-- **Virtual Printer Queue Mode** - Auto-archive and queue prints from slicer
-- **Virtual Printer Model Selection** - Choose which printer model to emulate
-- **TLS 1.3 Encryption** - Secure MQTT + FTPS with auto-generated certificates
-
-### Print Queue & Scheduling
-- **Model-Based Queue Assignment** - Queue to "Any X1C", "Any P1S" with auto filament matching
-- **Multi-Printer Selection** - Send prints to multiple printers at once
-- **Per-Printer AMS Mapping** - Configure filament mapping individually per printer
-- **Queue Bulk Edit** - Select and edit multiple queue items at once
-- **Queue Only Mode** - Stage prints without auto-start, release when ready
-- **Unassigned Queue Items** - Queue items without assigned printer
-- **Add to Queue from File Manager** - Queue sliced files directly from library
-- **Print Queue Plate Selection** - Full print configuration in queue modal
-- **Deferred Archive Creation** - Archives created when prints start, not when queued
-
-### Smart Plugs & Automation
-- **MQTT Smart Plug Support** - Monitor energy from Zigbee2MQTT, Shelly, Tasmota
-- **Home Assistant Integration** - Control any HA switch/light as a smart plug
-- **HA Energy Sensors** - Use separate sensor entities for power monitoring
-- **Tasmota Discovery** - Auto-discover Tasmota devices on network
-- **Switchbar Widget** - Quick power toggle in sidebar
-- **Tasmota Admin Link** - Quick access to plug web interface
-
-### Camera & Streaming
-- **External Camera Support** - MJPEG, RTSP, HTTP snapshot cameras
-- **USB Camera Support** - V4L2 webcam support on Linux
-- **Build Plate Empty Detection** - AI-powered detection with multi-reference calibration
-- **OBS Streaming Overlay** - Embeddable page at `/overlay/:printerId`
-- **Camera Zoom & Fullscreen** - 100%-400% zoom with pan support
-- **Multiple Embedded Viewers** - Open multiple camera streams simultaneously
-- **Camera View Mode** - Choose between new window or embedded overlay
-- **Layer-Based Timelapse** - External camera timelapse on layer change
-- **Finish Photo in Notifications** - `{finish_photo_url}` template variable
-
-### File Manager
-- **STL Thumbnail Generation** - Auto-generate 3D previews for STL files
-- **ZIP File Support** - Upload and extract ZIP files directly
-- **Create Folder from ZIP** - Auto-create folder named after ZIP file
-- **File Manager Sorting** - Sort by name, size, or date
-- **File Manager Rename** - Rename files and folders directly
-- **File Manager Print Button** - Print directly from selection toolbar
-- **Resizable Sidebar** - Drag to adjust width (200-500px)
-- **Text Wrap Toggle** - Wrap long folder names instead of truncating
-- **Mobile Accessibility** - Touch-friendly with always-visible menus
-
-### Archives & Projects
-- **Multi-Plate Selection** - Select which plate to print from multi-plate 3MF
-- **Archive Plate Browsing** - Navigate plate thumbnails in archive cards
-- **External Links** - Link archives to Printables, Thingiverse, etc.
-- **Fusion 360 Attachments** - Attach F3D design files to archives
-- **Project Import/Export** - Export/import projects as ZIP with all files
-- **BOM Item Editing** - Edit Bill of Materials items after creation
-- **Bulk Project Assignment** - Assign multiple archives to project at once
-- **Project Parts Tracking** - Track parts separately from plates
-- **Tag Management** - Create, edit, and apply tags to archives
-- **Archive Comparison** - Compare 2-5 archives side-by-side
-- **AMS Filament Preview** - Preview filament colors in archive cards
-
-### Printer Controls
-- **Printer Controls** - Stop and Pause/Resume buttons with confirmation
-- **Skip Objects** - Skip individual objects without canceling print
-- **Chamber Light Control** - Light toggle button on printer cards
-- **Resizable Printer Cards** - Four sizes (S/M/L/XL)
-- **H2D Pro Support** - Full support for H2D Pro printer model
-
-### AMS & Filament
-- **AMS Color Mapping** - Manual slot selection with auto-matching
-- **Expandable Color Picker** - 32 colors in configurable palette
-- **AMS Slot RFID Re-read** - Re-read filament info via hover menu
-- **Print Options in Modals** - Bed leveling, flow cal, vibration cal, timelapse toggles
-
-### Backup & Monitoring
-- **GitHub Profile Backup** - Scheduled backup to GitHub repository
-- **Prometheus Metrics** - Export telemetry for Grafana
-- **MQTT Publishing** - Publish events to external MQTT brokers
-- **Application Log Viewer** - Real-time log viewing with filters
-- **Support Bundle** - Debug logging with ZIP generation
-- **Comprehensive Backup/Restore** - All settings, users, groups included
-
-### Notifications
-- **HMS Error Notifications** - 853 error codes translated to human-readable messages
-- **Plate Not Empty Notification** - Dedicated category for plate detection
-- **Daily Digest** - Consolidated daily notification summary
-- **Notification Templates** - Customizable message templates
-- **Slack/Mattermost Format** - Proper payload format support
-
-### Statistics & Dashboard
-- **Failure Analysis Widget** - Failure rate with correlations and trends
-- **Statistics Improvements** - Size-aware responsive widgets
-- **Recalculate Costs** - Button to recalculate all archive costs
-- **Time Format Setting** - Configurable date/time format
-- **Print Quantity Tracking** - Track items per print for progress
-
-### Other Improvements
-- **Firmware Update Helper** - Check versions against Bambu Lab servers
-- **Disable Firmware Checks** - Toggle to prevent update checks
-- **Printer Discovery** - Docker subnet scanning, model mapping
-- **FTP Reliability** - Configurable retry with SSL fixes
-- **Pre-built Docker Images** - Pull from GitHub Container Registry
-- **One-Shot Install Scripts** - Simple `curl | bash` installation
-- **Mobile PWA** - Full mobile support with touch gestures
-- **Timelapse Editor** - Trim, speed adjustment, music overlay
-- **Sidebar Badge Indicators** - Queue and upload counts
-
----
-
-## Bug Fixes
-
-### Print Queue & Scheduling
-- **Home Assistant Auto-On for Queued Prints** - Fixed smart plug not turning on for queue-started prints (Issue #200)
-- **AMS Mapping for Model-Based Queue** - Fixed "Any [Model]" queue jobs failing at filament loading (Issue #192)
-- **Queue prints on A1** - Fixed "MicroSD Card read/write exception error" when starting prints from queue
-- **Multi-Plate Queue Thumbnails** - Queue now shows correct plate thumbnail (Issue #166)
-- **Queue items with library files** - Fixed 500 errors when listing/updating queue items from File Manager
-
-### Printer Status & Display
-- **A1/A1 Mini Status Display** - Fixed incorrect "Printing" status when idle (Issue #168)
-- **Chamber temp on A1/P1S** - Fixed regression where chamber temperature appeared on printers without sensors
-- **Active AMS slot display** - Fixed for H2D printers with multiple AMS units
-- **Printer hour counter** - Fixed not incrementing during prints and inconsistency between views
-
-### AMS & Filament
-- **Empty AMS Slot Recognition** - Fixed removed spools still appearing in Bambuddy (Issue #147)
-- **Spoolman Sync for Transparent Spools** - Fixed sync failures for natural/transparent filaments (Issue #190)
-- **Spoolman tag field** - Now auto-created on first connect, fixing fresh installs (Issue #123)
-- **Spoolman 400 Bad Request** - Fixed when creating spools
-- **AMS filament matching** - Fixed in reprint modal
-- **User preset AMS configuration** - Fixed user presets showing empty fields in Bambu Studio
-
-### Notifications & Webhooks
-- **Progress Milestone Notifications** - Fixed showing wrong time (e.g., "17m" instead of "17h 47m") (Issue #157)
-- **Mattermost/Slack Webhooks** - Added proper payload format support (Issue #133)
-- **Telegram Notification Parsing** - Fixed markdown errors with underscores in error codes
-- **HMS Error Notifications** - 853 error codes now translated to human-readable messages
-- **Notifications sent when printer offline** - Fixed
-
-### Camera & Streaming
-- **Camera stream reconnection** - Automatic recovery from stalled streams
-- **Camera zoom & pan** - Fixed pan range and added pinch-to-zoom for mobile (Issue #132)
-- **P2S/X1E/H2 completion photo** - Fixed internal model codes not recognized (Issue #127)
-- **Browser freeze** - Fixed on print completion when camera stream was open
-- **ffmpeg processes** - Fixed not being killed when closing webcam window
-
-### File Manager & Archives
-- **P2S Empty Archive Tiles** - Fixed FTP search for printers without SD card (Issue #146)
-- **File Manager folder navigation** - Fixed folder opening then jumping back to root (Issue #160)
-- **File Manager upload** - Now accepts all file types, not just ZIP
-- **Multi-plate 3MF metadata** - Single-plate exports now show correct thumbnail
-- **Archive card cache** - Fixed wrong cover image bug
-- **Archive delete safety** - Added checks to prevent deleting parent directories
-
-### Statistics & Tracking
-- **Print time stats** - Now uses actual elapsed time instead of slicer estimates (Issue #137)
-- **Filament cost** - Now uses "Default filament cost" setting instead of hardcoded €25 (Issue #120)
-- **Reprint cost tracking** - Now adds cost to existing total instead of replacing
-- **K-Profiles backup status** - Fixed showing incorrect printer connection count
-
-### Smart Plugs
-- **HA Energy Sensors** - Fixed sensors with lowercase units (w, kwh) not detected (Issue #119)
-
-### UI & UX
-- **Skip objects modal overflow** - Fixed modal going above browser window (Issue #134)
-- **Project card filament badges** - Fixed showing duplicates and raw color codes
-- **Subnet scan serial number** - Fixed A1 Mini showing "unknown-*" placeholder (Issue #140)
-- **Slicer protocol** - Fixed OS detection (Windows vs macOS/Linux)
-
-### API & Backend
-- **Settings API PATCH Method** - Added for Home Assistant rest_command compatibility (Issue #152)
-- **GitHub Backup Timestamps** - Removed volatile timestamps for cleaner git diffs
-- **Plate Calibration Persistence** - Fixed reference images not persisting in Docker
-- **Update module** - Fixed for Docker-based installations
-
----
-
-## Maintenance
-
-- Upgraded vitest from 2.x to 3.x for security improvements
-- Added security scanning (pip-audit, npm audit) to CI pipeline
-- Replaced python-jose with PyJWT to eliminate ecdsa vulnerability
-- Improved test coverage (796 backend tests, 518 frontend tests)
-
----
-
-## Thank You!
-
-This release wouldn't be possible without our amazing community. A huge thank you to everyone who contributed code, reported bugs, tested beta releases, and provided feedback!
-
-### Code Contributors
-
-| Contributor | Contribution |
-|-------------|--------------|
-| **[@maziggy](https://github.com/maziggy)** (MartinNYHC) | Lead developer, core features |
-| **[@MisterBeardy](https://github.com/MisterBeardy)** (Wesley Reaves) | STL thumbnail generation |
-| **[@JesseFPV](https://github.com/JesseFPV)** (Jesse Hulswit) | Optional authentication system |
-
-### Issue Reporters & Testers
-
-Special thanks to everyone who reported issues, tested beta releases, and provided valuable feedback:
-
-- **[@Locxion](https://github.com/Locxion)** (Markus Bender) - A1 Mini status bug, log viewer feature
-- **[@cadtoolbox](https://github.com/cadtoolbox)** (Thomas Rambach) - H2D Pro support, model-based queue
-- **[@Twilek-de](https://github.com/Twilek-de)** - Empty AMS slots, Mattermost webhooks, progress milestones
-- **[@elit3ge](https://github.com/elit3ge)** - Archive tiles, completion photos, upload improvements
-- **[@opensourcefan](https://github.com/opensourcefan)** - Subnet scan serial, status colors
-- **[@1nv4lidus3r](https://github.com/1nv4lidus3r)** - Print time stats, skip objects modal
-- **[@joaorgoncalves](https://github.com/joaorgoncalves)** (João Gonçalves) - Filament cost settings, HA energy sync
-- **[@beardofbeespool](https://github.com/beardofbeespool)** (Morton Likely) - STL thumbnail feature request
-- **[@PeterXQChen](https://github.com/PeterXQChen)** (Peter Chen) - File manager folder navigation
-- **[@Robnex](https://github.com/Robnex)** - External links, external spool sync
-- **[@caco3](https://github.com/caco3)** (CaCO3) - Disable firmware checks
-- **[@sbcrumb](https://github.com/sbcrumb)** - Camera zoom feature
-- **[@fcps3](https://github.com/fcps3)** - Spoolman transparent spool sync
-- **[@LucHeart](https://github.com/LucHeart)** - MQTT connection issues
-- **[@ouihq](https://github.com/ouihq)** (Jonas) - File manager queue bug
-- **[@Schuermi7](https://github.com/Schuermi7)** - AMS mapping, cloud 2FA
-- **[@stubbers](https://github.com/stubbers)** (Joseph Stubberfield) - X1C sync issues
-- **[@nvdmedianl](https://github.com/nvdmedianl)** (Nathan) - Spoolman Bambu spool errors
-- **[@lbeumer-bit](https://github.com/lbeumer-bit)** - Notification photos
-- **[@IROKILLER](https://github.com/IROKILLER)** - Home Assistant automations
-- **[@fgrfn](https://github.com/fgrfn)** (Florian) - Dynamic electricity cost
-- **[@JasonSwindle](https://github.com/JasonSwindle)** (Jason Swindle) - Smart plug text overflow
-- **[@Cassiopeia1980](https://github.com/Cassiopeia1980)** - Connection issues
-
-And many more community members who tested, provided feedback, and helped make Bambuddy better!
-
----
-
-## Upgrade Notes
-
-### From 0.1.5.x or earlier
-- Database migrations run automatically on startup
-- User authentication is optional and disabled by default
-- Existing installations will continue to work without changes
-
-### From 0.1.6 beta
-- All beta migrations are included in the final release
-- No action required - just update and restart
-
----
-
-## What's Next?
-
-We're already planning 0.1.7 with more exciting features. Stay tuned and keep the feedback coming!
-
-- [GitHub Issues](https://github.com/MisterBeardy/bambuddy/issues) - Report bugs and request features
-- [GitHub Discussions](https://github.com/MisterBeardy/bambuddy/discussions) - Join the conversation
-
----
-
-**Happy Printing!** 🎉
-
-*— The Bambuddy Team*

+ 5 - 0
backend/app/api/routes/ams_history.py

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

+ 11 - 2
backend/app/api/routes/api_keys.py

@@ -4,9 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import generate_api_key
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, generate_api_key
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
+from backend.app.models.user import User
 from backend.app.schemas.api_key import (
     APIKeyCreate,
     APIKeyCreateResponse,
@@ -20,7 +22,10 @@ router = APIRouter(prefix="/api-keys", tags=["api-keys"])
 
 
 @router.get("/", response_model=list[APIKeyResponse])
-async def list_api_keys(db: AsyncSession = Depends(get_db)):
+async def list_api_keys(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
+):
     """List all API keys (without full key values)."""
     result = await db.execute(select(APIKey).order_by(APIKey.created_at.desc()))
     return list(result.scalars().all())
@@ -30,6 +35,7 @@ async def list_api_keys(db: AsyncSession = Depends(get_db)):
 async def create_api_key(
     data: APIKeyCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_CREATE),
 ):
     """Create a new API key.
 
@@ -74,6 +80,7 @@ async def create_api_key(
 async def get_api_key(
     key_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_READ),
 ):
     """Get an API key by ID."""
     result = await db.execute(select(APIKey).where(APIKey.id == key_id))
@@ -90,6 +97,7 @@ async def update_api_key(
     key_id: int,
     data: APIKeyUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_UPDATE),
 ):
     """Update an API key."""
     result = await db.execute(select(APIKey).where(APIKey.id == key_id))
@@ -124,6 +132,7 @@ async def update_api_key(
 async def delete_api_key(
     key_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.API_KEYS_DELETE),
 ):
     """Delete (revoke) an API key."""
     result = await db.execute(select(APIKey).where(APIKey.id == key_id))

+ 141 - 27
backend/app/api/routes/archives.py

@@ -8,7 +8,10 @@ from fastapi.responses import FileResponse, Response
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    require_ownership_permission,
+)
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -118,6 +121,7 @@ async def list_archives(
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """List archived prints."""
     service = ArchiveService(db)
@@ -148,6 +152,7 @@ async def search_archives(
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Full-text search across archives.
 
@@ -229,7 +234,10 @@ async def search_archives(
 
 
 @router.post("/search/rebuild-index")
-async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
+async def rebuild_search_index(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Rebuild the full-text search index from existing archives.
 
     Use this if search results seem incomplete or incorrect.
@@ -267,6 +275,7 @@ async def analyze_failures(
     printer_id: int | None = None,
     project_id: int | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Analyze failure patterns across prints.
 
@@ -291,6 +300,7 @@ async def analyze_failures(
 async def compare_archives(
     archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Compare multiple archives side by side.
 
@@ -331,6 +341,7 @@ async def export_archives(
     date_to: str | None = Query(None, description="End date (ISO format)"),
     search: str | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Export archives to CSV or Excel format.
 
@@ -393,6 +404,7 @@ async def export_stats(
     printer_id: int | None = None,
     project_id: int | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
 ):
     """Export statistics summary to CSV or Excel format."""
     from fastapi.responses import StreamingResponse
@@ -421,7 +433,10 @@ async def export_stats(
 
 
 @router.get("/stats", response_model=ArchiveStats)
-async def get_archive_stats(db: AsyncSession = Depends(get_db)):
+async def get_archive_stats(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
+):
     """Get statistics across all archives."""
     # Total counts
     total_result = await db.execute(select(func.count(PrintArchive.id)))
@@ -451,7 +466,10 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
             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)))
+    # Multiply filament by quantity to account for multiple items printed
+    filament_result = await db.execute(
+        select(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)))
+    )
     total_filament = filament_result.scalar() or 0
 
     cost_result = await db.execute(select(func.sum(PrintArchive.cost)))
@@ -571,7 +589,10 @@ async def get_archive_stats(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/tags")
-async def get_all_tags(db: AsyncSession = Depends(get_db)):
+async def get_all_tags(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """List all unique tags with usage counts.
 
     Returns a list of tags sorted by count (descending), then by name.
@@ -601,6 +622,7 @@ async def rename_tag(
     tag_name: str,
     request: Request,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Rename a tag across all archives.
 
@@ -643,7 +665,11 @@ async def rename_tag(
 
 
 @router.delete("/tags/{tag_name}")
-async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
+async def delete_tag(
+    tag_name: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Delete a tag from all archives.
 
     Returns the count of affected archives.
@@ -668,7 +694,11 @@ async def delete_tag(tag_name: str, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{archive_id}", response_model=ArchiveResponse)
-async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_archive(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Get a specific archive."""
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -691,6 +721,7 @@ async def find_similar_archives(
     archive_id: int,
     limit: int = 10,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Find archives with similar settings for comparison.
 
@@ -759,6 +790,7 @@ async def update_archive(
 async def toggle_favorite(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
     """Toggle favorite status for an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -773,7 +805,11 @@ async def toggle_favorite(
 
 
 @router.post("/{archive_id}/rescan", response_model=ArchiveResponse)
-async def rescan_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def rescan_archive(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Rescan the 3MF file and update metadata."""
     from backend.app.api.routes.settings import get_setting
     from backend.app.services.archive import ThreeMFParser
@@ -832,7 +868,10 @@ 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)):
+async def recalculate_all_costs(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Recalculate costs for all archives based on filament usage and prices."""
     from backend.app.api.routes.settings import get_setting
 
@@ -862,7 +901,10 @@ async def recalculate_all_costs(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/rescan-all")
-async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
+async def rescan_all_archives(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Rescan all archives and update their metadata."""
     from backend.app.services.archive import ThreeMFParser
 
@@ -909,7 +951,11 @@ async def rescan_all_archives(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{archive_id}/duplicates")
-async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_archive_duplicates(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Get duplicates for a specific archive."""
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -927,7 +973,10 @@ async def get_archive_duplicates(archive_id: int, db: AsyncSession = Depends(get
 
 
 @router.post("/backfill-hashes")
-async def backfill_content_hashes(db: AsyncSession = Depends(get_db)):
+async def backfill_content_hashes(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+):
     """Compute and store content hashes for all archives missing them."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None)))
     archives = list(result.scalars().all())
@@ -988,6 +1037,7 @@ async def download_archive(
     archive_id: int,
     inline: bool = False,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Download the 3MF file."""
     service = ArchiveService(db)
@@ -1015,6 +1065,7 @@ async def download_archive_with_filename(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
     service = ArchiveService(db)
@@ -1034,8 +1085,14 @@ async def download_archive_with_filename(
 
 
 @router.get("/{archive_id}/thumbnail")
-async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
-    """Get the thumbnail image."""
+async def get_thumbnail(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the thumbnail image.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     if not archive or not archive.thumbnail_path:
@@ -1059,8 +1116,14 @@ async def get_thumbnail(archive_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{archive_id}/timelapse")
-async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
-    """Get the timelapse video."""
+async def get_timelapse(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get the timelapse video.
+
+    Note: Unauthenticated - loaded via <video> tags which can't send auth headers.
+    """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     if not archive or not archive.timelapse_path:
@@ -1088,6 +1151,7 @@ async def get_timelapse(archive_id: int, db: AsyncSession = Depends(get_db)):
 async def scan_timelapse(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Scan printer for timelapse matching this archive and attach it."""
     from backend.app.models.printer import Printer
@@ -1314,6 +1378,7 @@ async def select_timelapse(
     archive_id: int,
     filename: str = Query(..., description="Timelapse filename to attach"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Manually select a timelapse from the printer to attach."""
     from backend.app.models.printer import Printer
@@ -1398,6 +1463,7 @@ async def upload_timelapse(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Manually upload a timelapse video to an archive."""
     service = ArchiveService(db)
@@ -1418,7 +1484,11 @@ async def upload_timelapse(
 
 
 @router.get("/{archive_id}/timelapse/info")
-async def get_timelapse_info(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_timelapse_info(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Get timelapse video metadata for editor."""
     from backend.app.schemas.timelapse import TimelapseInfoResponse
     from backend.app.services.timelapse_processor import TimelapseProcessor
@@ -1447,6 +1517,7 @@ async def get_timelapse_thumbnails(
     count: int = Query(10, ge=1, le=30),
     width: int = Query(160, ge=80, le=320),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Generate timeline thumbnail frames for visual scrubbing."""
     import base64
@@ -1486,6 +1557,7 @@ async def process_timelapse(
     output_filename: str = Form(None),
     audio: UploadFile = File(None),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Process timelapse with trim, speed, and optional audio overlay."""
     import shutil
@@ -1589,6 +1661,7 @@ async def upload_photo(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
     """Upload a photo of the printed result."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -1633,7 +1706,10 @@ async def get_photo(
     filename: str,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get a specific photo."""
+    """Get a specific photo.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     if not archive:
@@ -1663,6 +1739,7 @@ async def delete_photo(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
 ):
     """Delete a photo."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -1700,7 +1777,10 @@ async def get_qrcode(
     size: int = 200,
     db: AsyncSession = Depends(get_db),
 ):
-    """Generate a QR code that links to this archive."""
+    """Generate a QR code that links to this archive.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     try:
         import qrcode
         from PIL import Image as PILImage
@@ -1748,7 +1828,11 @@ async def get_qrcode(
 
 
 @router.get("/{archive_id}/capabilities")
-async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_archive_capabilities(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Check what viewing capabilities are available for this 3MF file."""
     import json
     import xml.etree.ElementTree as ET
@@ -1965,7 +2049,11 @@ async def get_archive_capabilities(archive_id: int, db: AsyncSession = Depends(g
 
 
 @router.get("/{archive_id}/gcode")
-async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_gcode(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Extract and return G-code from the 3MF file."""
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -1998,11 +2086,16 @@ async def get_gcode(archive_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{archive_id}/plate-preview")
-async def get_plate_preview(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_plate_preview(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+):
     """Get the plate preview image from the 3MF file.
 
     Returns the slicer-generated plate thumbnail which shows the model
     with correct colors and positioning.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
     """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
@@ -2065,7 +2158,7 @@ async def upload_archive(
     file: UploadFile = File(...),
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
 ):
     """Manually upload a 3MF file to archive."""
     if not file.filename or not file.filename.endswith(".3mf"):
@@ -2100,7 +2193,7 @@ async def upload_archives_bulk(
     files: list[UploadFile] = File(...),
     printer_id: int | None = None,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
 ):
     """Bulk upload multiple 3MF files to archive."""
     results = []
@@ -2154,6 +2247,7 @@ async def upload_archives_bulk(
 async def get_archive_plates(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Get available plates from a multi-plate 3MF archive.
 
@@ -2399,7 +2493,10 @@ async def get_plate_thumbnail(
     plate_index: int,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get the thumbnail image for a specific plate."""
+    """Get the thumbnail image for a specific plate.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     if not archive:
@@ -2426,6 +2523,7 @@ async def get_filament_requirements(
     archive_id: int,
     plate_id: int | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Get filament requirements from the archived 3MF file.
 
@@ -2721,7 +2819,11 @@ async def reprint_archive(
 
 
 @router.get("/{archive_id}/project-page")
-async def get_project_page(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_project_page(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
     """Get the project page data from the 3MF file."""
     from backend.app.schemas.archive import ProjectPageResponse
     from backend.app.services.archive import ProjectPageParser
@@ -2746,6 +2848,7 @@ async def update_project_page(
     archive_id: int,
     update_data: dict,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
     """Update project page metadata in the 3MF file."""
     from backend.app.services.archive import ProjectPageParser
@@ -2776,7 +2879,10 @@ async def get_project_image(
     image_path: str,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get an image from the 3MF project page."""
+    """Get an image from the 3MF project page.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     from backend.app.services.archive import ProjectPageParser
 
     service = ArchiveService(db)
@@ -2812,6 +2918,7 @@ async def upload_source_3mf(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
     """Upload the original source 3MF project file for an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2858,6 +2965,7 @@ async def upload_source_3mf(
 async def download_source_3mf(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Download the source 3MF project file."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2887,6 +2995,7 @@ async def download_source_3mf_for_slicer(
     archive_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -2913,6 +3022,7 @@ async def upload_source_3mf_by_name(
     file: UploadFile = File(...),
     print_name: str = Query(None, description="Match archive by print name"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
 ):
     """Upload source 3MF and match to archive by print name.
 
@@ -2999,6 +3109,7 @@ async def upload_source_3mf_by_name(
 async def delete_source_3mf(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
 ):
     """Delete the source 3MF project file from an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -3031,6 +3142,7 @@ async def upload_f3d(
     archive_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_OWN),
 ):
     """Upload a Fusion 360 design file for an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -3077,6 +3189,7 @@ async def upload_f3d(
 async def download_f3d(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
     """Download the Fusion 360 design file."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -3105,6 +3218,7 @@ async def download_f3d(
 async def delete_f3d(
     archive_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_DELETE_OWN),
 ):
     """Delete the Fusion 360 design file from an archive."""
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))

+ 28 - 3
backend/app/api/routes/camera.py

@@ -9,8 +9,11 @@ from fastapi.responses import Response, StreamingResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.services.camera import (
     capture_camera_frame,
     generate_chamber_image_stream,
@@ -353,6 +356,8 @@ async def camera_stream(
     This endpoint returns a multipart MJPEG stream that can be used directly
     in an <img> tag or video player.
 
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+
     Uses external camera if configured, otherwise uses built-in camera:
     - External: MJPEG, RTSP, or HTTP snapshot
     - A1/P1: Chamber image protocol (port 6000)
@@ -476,7 +481,10 @@ async def camera_stream(
 
 
 @router.api_route("/{printer_id}/camera/stop", methods=["GET", "POST"])
-async def stop_camera_stream(printer_id: int):
+async def stop_camera_stream(
+    printer_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+):
     """Stop all active camera streams for a printer.
 
     This can be called by the frontend when the camera window is closed.
@@ -527,6 +535,8 @@ async def camera_snapshot(
     """Capture a single frame from the printer camera.
 
     Returns a JPEG image.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
     """
     import tempfile
     from pathlib import Path
@@ -574,6 +584,7 @@ async def camera_snapshot(
 async def test_camera(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Test camera connection for a printer.
 
@@ -591,7 +602,10 @@ async def test_camera(
 
 
 @router.get("/{printer_id}/camera/status")
-async def camera_status(printer_id: int):
+async def camera_status(
+    printer_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+):
     """Get the status of an active camera stream.
 
     Returns whether a stream is active and when the last frame was received.
@@ -658,6 +672,7 @@ async def test_external_camera(
     url: str,
     camera_type: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Test external camera connection.
 
@@ -684,6 +699,7 @@ async def check_plate_empty(
     use_external: bool = False,
     include_debug_image: bool = False,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Check if the build plate is empty using camera vision.
 
@@ -791,6 +807,7 @@ async def calibrate_plate_detection(
     label: str | None = None,
     use_external: bool = False,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Calibrate plate detection by capturing a reference image of the empty plate.
 
@@ -854,6 +871,7 @@ async def delete_plate_calibration(
     printer_id: int,
     plate_type: str | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Delete the plate detection calibration for a printer and plate type.
 
@@ -894,6 +912,7 @@ async def get_plate_detection_status(
     printer_id: int,
     plate_type: str | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Check plate detection status for a printer and plate type.
 
@@ -937,6 +956,7 @@ async def get_plate_detection_status(
 async def get_plate_references(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Get all calibration references for a printer with metadata.
 
@@ -971,7 +991,10 @@ async def get_reference_thumbnail(
     index: int,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get thumbnail image for a calibration reference."""
+    """Get thumbnail image for a calibration reference.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     from fastapi.responses import Response
 
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
@@ -997,6 +1020,7 @@ async def update_reference_label(
     index: int,
     label: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Update the label for a calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available
@@ -1021,6 +1045,7 @@ async def delete_reference(
     printer_id: int,
     index: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
     """Delete a specific calibration reference."""
     from backend.app.services.plate_detection import PlateDetector, is_plate_detection_available

+ 84 - 19
backend/app/api/routes/cloud.py

@@ -13,8 +13,11 @@ from fastapi import APIRouter, Body, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.settings import Settings
+from backend.app.models.user import User
 from backend.app.schemas.cloud import (
     CloudAuthStatus,
     CloudDevice,
@@ -74,7 +77,10 @@ async def clear_token(db: AsyncSession) -> None:
 
 
 @router.get("/status", response_model=CloudAuthStatus)
-async def get_auth_status(db: AsyncSession = Depends(get_db)):
+async def get_auth_status(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+):
     """Get current cloud authentication status."""
     token, email = await get_stored_token(db)
     cloud = get_cloud_service()
@@ -89,12 +95,20 @@ async def get_auth_status(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/login", response_model=CloudLoginResponse)
-async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
+async def login(
+    request: CloudLoginRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+):
     """
     Initiate login to Bambu Cloud.
 
-    This will typically trigger a verification code to be sent to the user's email.
-    After receiving the code, call /cloud/verify to complete the login.
+    This will trigger either:
+    - Email verification: A code is sent to the user's email
+    - TOTP verification: User enters code from their authenticator app
+
+    After receiving/generating the code, call /cloud/verify to complete the login.
+    For TOTP, include the tfa_key from this response in the verify request.
     """
     cloud = get_cloud_service()
 
@@ -112,6 +126,8 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
             success=result.get("success", False),
             needs_verification=result.get("needs_verification", False),
             message=result.get("message", "Unknown error"),
+            verification_type=result.get("verification_type"),
+            tfa_key=result.get("tfa_key"),
         )
     except BambuCloudAuthError as e:
         raise HTTPException(status_code=401, detail=str(e))
@@ -120,17 +136,30 @@ async def login(request: CloudLoginRequest, db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/verify", response_model=CloudLoginResponse)
-async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(get_db)):
+async def verify_code(
+    request: CloudVerifyRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+):
     """
-    Complete login with verification code.
+    Complete login with verification code (email or TOTP).
+
+    For email verification:
+    - After calling /cloud/login, the user receives an email with a 6-digit code
+    - Submit the code with email address
 
-    After calling /cloud/login, the user will receive an email with a 6-digit code.
-    Submit that code here to complete authentication.
+    For TOTP verification:
+    - The user enters the 6-digit code from their authenticator app
+    - Include the tfa_key from the /cloud/login response
     """
     cloud = get_cloud_service()
 
     try:
-        result = await cloud.verify_code(request.email, request.code)
+        # Use TOTP verification if tfa_key is provided
+        if request.tfa_key:
+            result = await cloud.verify_totp(request.tfa_key, request.code)
+        else:
+            result = await cloud.verify_code(request.email, request.code)
 
         if result.get("success") and cloud.access_token:
             await store_token(db, cloud.access_token, request.email)
@@ -147,7 +176,11 @@ async def verify_code(request: CloudVerifyRequest, db: AsyncSession = Depends(ge
 
 
 @router.post("/token", response_model=CloudAuthStatus)
-async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_db)):
+async def set_token(
+    request: CloudTokenRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+):
     """
     Set access token directly.
 
@@ -167,7 +200,10 @@ async def set_token(request: CloudTokenRequest, db: AsyncSession = Depends(get_d
 
 
 @router.post("/logout")
-async def logout(db: AsyncSession = Depends(get_db)):
+async def logout(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CLOUD_AUTH),
+):
     """Log out of Bambu Cloud."""
     cloud = get_cloud_service()
     cloud.logout()
@@ -179,6 +215,7 @@ async def logout(db: AsyncSession = Depends(get_db)):
 async def get_slicer_settings(
     version: str = "02.04.00.70",
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
     """
     Get all slicer settings (filament, printer, process presets).
@@ -235,7 +272,11 @@ async def get_slicer_settings(
 
 
 @router.get("/settings/{setting_id}")
-async def get_setting_detail(setting_id: str, db: AsyncSession = Depends(get_db)):
+async def get_setting_detail(
+    setting_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """
     Get detailed information for a specific setting/preset.
 
@@ -296,7 +337,11 @@ def _filament_id_to_setting_id(filament_id: str) -> str:
 
 
 @router.post("/filament-info")
-async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession = Depends(get_db)):
+async def get_filament_info(
+    setting_ids: list[str] = Body(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """
     Get filament preset info (name and K value) for multiple setting IDs.
 
@@ -370,7 +415,10 @@ async def get_filament_info(setting_ids: list[str] = Body(...), db: AsyncSession
 
 
 @router.get("/devices", response_model=list[CloudDevice])
-async def get_devices(db: AsyncSession = Depends(get_db)):
+async def get_devices(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+):
     """
     Get list of bound printer devices.
 
@@ -408,7 +456,10 @@ async def get_devices(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/firmware-updates", response_model=FirmwareUpdatesResponse)
-async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
+async def get_firmware_updates(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+):
     """
     Check for firmware updates for all bound devices.
 
@@ -484,7 +535,11 @@ async def get_firmware_updates(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/settings")
-async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)):
+async def create_setting(
+    request: SlicerSettingCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """
     Create a new slicer preset/setting.
 
@@ -524,6 +579,7 @@ async def update_setting(
     setting_id: str,
     request: SlicerSettingUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """
     Update an existing slicer preset/setting.
@@ -555,7 +611,11 @@ async def update_setting(
 
 
 @router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
-async def delete_setting(setting_id: str, db: AsyncSession = Depends(get_db)):
+async def delete_setting(
+    setting_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """
     Delete a slicer preset/setting.
 
@@ -620,7 +680,10 @@ def _load_fields(preset_type: str) -> dict:
 
 
 @router.get("/fields/{preset_type}")
-async def get_preset_fields(preset_type: Literal["filament", "print", "process", "printer"]):
+async def get_preset_fields(
+    preset_type: Literal["filament", "print", "process", "printer"],
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """
     Get field definitions for a preset type.
 
@@ -639,7 +702,9 @@ async def get_preset_fields(preset_type: Literal["filament", "print", "process",
 
 
 @router.get("/fields")
-async def get_all_preset_fields():
+async def get_all_preset_fields(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """
     Get all field definitions for all preset types.
 

+ 29 - 8
backend/app/api/routes/discovery.py

@@ -10,6 +10,9 @@ import logging
 from fastapi import APIRouter
 from pydantic import BaseModel
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
 from backend.app.services.discovery import (
     discovery_service,
     is_running_in_docker,
@@ -60,7 +63,9 @@ class DiscoveredPrinterResponse(BaseModel):
 
 
 @router.get("/info", response_model=DiscoveryInfo)
-async def get_discovery_info():
+async def get_discovery_info(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Get discovery environment info (Docker detection, etc.)."""
     return DiscoveryInfo(
         is_docker=is_running_in_docker(),
@@ -70,13 +75,18 @@ async def get_discovery_info():
 
 
 @router.get("/status", response_model=DiscoveryStatus)
-async def get_discovery_status():
+async def get_discovery_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Get the current SSDP discovery status."""
     return DiscoveryStatus(running=discovery_service.is_running)
 
 
 @router.post("/start", response_model=DiscoveryStatus)
-async def start_discovery(duration: float = 10.0):
+async def start_discovery(
+    duration: float = 10.0,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Start SSDP printer discovery.
 
     Args:
@@ -87,14 +97,18 @@ async def start_discovery(duration: float = 10.0):
 
 
 @router.post("/stop", response_model=DiscoveryStatus)
-async def stop_discovery():
+async def stop_discovery(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Stop SSDP printer discovery."""
     await discovery_service.stop()
     return DiscoveryStatus(running=discovery_service.is_running)
 
 
 @router.get("/printers", response_model=list[DiscoveredPrinterResponse])
-async def get_discovered_printers():
+async def get_discovered_printers(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Get list of discovered printers (from both SSDP and subnet scan)."""
     # Combine results from both discovery methods
     printers = {}
@@ -124,7 +138,10 @@ async def get_discovered_printers():
 
 
 @router.post("/scan", response_model=SubnetScanStatus)
-async def start_subnet_scan(request: SubnetScanRequest):
+async def start_subnet_scan(
+    request: SubnetScanRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Start a subnet scan for Bambu printers.
 
     Use this when running in Docker where SSDP multicast doesn't work.
@@ -147,7 +164,9 @@ async def start_subnet_scan(request: SubnetScanRequest):
 
 
 @router.get("/scan/status", response_model=SubnetScanStatus)
-async def get_scan_status():
+async def get_scan_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Get the current subnet scan status."""
     scanned, total = subnet_scanner.progress
     return SubnetScanStatus(
@@ -158,7 +177,9 @@ async def get_scan_status():
 
 
 @router.post("/scan/stop", response_model=SubnetScanStatus)
-async def stop_subnet_scan():
+async def stop_subnet_scan(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.DISCOVERY_SCAN),
+):
     """Stop the current subnet scan."""
     subnet_scanner.stop()
     scanned, total = subnet_scanner.progress

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

@@ -9,9 +9,12 @@ from fastapi.responses import FileResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.external_link import ExternalLink
+from backend.app.models.user import User
 from backend.app.schemas.external_link import (
     ExternalLinkCreate,
     ExternalLinkReorder,
@@ -29,7 +32,10 @@ router = APIRouter(prefix="/external-links", tags=["external-links"])
 
 
 @router.get("/", response_model=list[ExternalLinkResponse])
-async def list_external_links(db: AsyncSession = Depends(get_db)):
+async def list_external_links(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
+):
     """List all external links ordered by sort_order."""
     result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order, ExternalLink.id))
     links = result.scalars().all()
@@ -40,6 +46,7 @@ async def list_external_links(db: AsyncSession = Depends(get_db)):
 async def create_external_link(
     link_data: ExternalLinkCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_CREATE),
 ):
     """Create a new external link."""
     # Get the highest sort_order to place new link at end
@@ -67,6 +74,7 @@ async def create_external_link(
 async def get_external_link(
     link_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_READ),
 ):
     """Get a specific external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -83,6 +91,7 @@ async def update_external_link(
     link_id: int,
     update_data: ExternalLinkUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Update an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -108,6 +117,7 @@ async def update_external_link(
 async def delete_external_link(
     link_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_DELETE),
 ):
     """Delete an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -129,6 +139,7 @@ async def delete_external_link(
 async def reorder_external_links(
     reorder_data: ExternalLinkReorder,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Update the sort order of external links."""
     # Update sort_order for each link based on position in the list
@@ -154,6 +165,7 @@ async def upload_icon(
     link_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Upload a custom icon for an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -202,6 +214,7 @@ async def upload_icon(
 async def delete_icon(
     link_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.EXTERNAL_LINKS_UPDATE),
 ):
     """Delete the custom icon for an external link."""
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
@@ -227,7 +240,10 @@ async def get_icon(
     link_id: int,
     db: AsyncSession = Depends(get_db),
 ):
-    """Get the custom icon for an external link."""
+    """Get the custom icon for an external link.
+
+    Note: Unauthenticated - loaded via <img> tags which can't send auth headers.
+    """
     result = await db.execute(select(ExternalLink).where(ExternalLink.id == link_id))
     link = result.scalar_one_or_none()
 

+ 25 - 4
backend/app/api/routes/filaments.py

@@ -2,8 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.filament import Filament
+from backend.app.models.user import User
 from backend.app.schemas.filament import (
     FilamentCostCalculation,
     FilamentCreate,
@@ -15,7 +18,10 @@ router = APIRouter(prefix="/filaments", tags=["filaments"])
 
 
 @router.get("/", response_model=list[FilamentResponse])
-async def list_filaments(db: AsyncSession = Depends(get_db)):
+async def list_filaments(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """List all filaments."""
     result = await db.execute(select(Filament).order_by(Filament.type, Filament.name))
     return list(result.scalars().all())
@@ -25,6 +31,7 @@ async def list_filaments(db: AsyncSession = Depends(get_db)):
 async def create_filament(
     filament_data: FilamentCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
 ):
     """Create a new filament entry."""
     filament = Filament(**filament_data.model_dump())
@@ -35,7 +42,11 @@ async def create_filament(
 
 
 @router.get("/{filament_id}", response_model=FilamentResponse)
-async def get_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
+async def get_filament(
+    filament_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get a specific filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
     filament = result.scalar_one_or_none()
@@ -49,6 +60,7 @@ async def update_filament(
     filament_id: int,
     filament_data: FilamentUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
     """Update a filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
@@ -65,7 +77,11 @@ async def update_filament(
 
 
 @router.delete("/{filament_id}")
-async def delete_filament(filament_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_filament(
+    filament_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_DELETE),
+):
     """Delete a filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
     filament = result.scalar_one_or_none()
@@ -82,6 +98,7 @@ async def calculate_cost(
     filament_id: int,
     weight_grams: float,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Calculate the cost for a given weight of filament."""
     result = await db.execute(select(Filament).where(Filament.id == filament_id))
@@ -104,6 +121,7 @@ async def calculate_cost(
 async def get_filaments_by_type(
     filament_type: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
 ):
     """Get all filaments of a specific type."""
     result = await db.execute(select(Filament).where(Filament.type.ilike(f"%{filament_type}%")).order_by(Filament.name))
@@ -111,7 +129,10 @@ async def get_filaments_by_type(
 
 
 @router.post("/seed-defaults")
-async def seed_default_filaments(db: AsyncSession = Depends(get_db)):
+async def seed_default_filaments(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_CREATE),
+):
     """Seed the database with common filament types."""
     defaults = [
         {

+ 14 - 2
backend/app/api/routes/firmware.py

@@ -12,8 +12,11 @@ from pydantic import BaseModel, Field
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.services.firmware_check import get_firmware_service
 from backend.app.services.firmware_update import (
     FirmwareUploadStatus,
@@ -59,6 +62,7 @@ class LatestFirmwareInfo(BaseModel):
 @router.get("/updates", response_model=FirmwareUpdatesResponse)
 async def check_firmware_updates(
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
     Check for firmware updates for all connected printers.
@@ -112,6 +116,7 @@ async def check_firmware_updates(
 async def check_printer_firmware(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
     Check for firmware update for a specific printer.
@@ -148,7 +153,9 @@ async def check_printer_firmware(
 
 
 @router.get("/latest", response_model=list[LatestFirmwareInfo])
-async def get_all_latest_firmware():
+async def get_all_latest_firmware(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+):
     """
     Get the latest firmware versions for all Bambu Lab printer models.
 
@@ -211,6 +218,7 @@ class FirmwareUploadStartResponse(BaseModel):
 async def prepare_firmware_upload(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
 ):
     """
     Check prerequisites for uploading firmware to a printer.
@@ -232,6 +240,7 @@ async def prepare_firmware_upload(
 async def start_firmware_upload(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_UPDATE),
 ):
     """
     Start uploading firmware to a printer's SD card.
@@ -279,7 +288,10 @@ async def start_firmware_upload(
 
 
 @router.get("/updates/{printer_id}/upload/status", response_model=FirmwareUploadStatusResponse)
-async def get_firmware_upload_status(printer_id: int):
+async def get_firmware_upload_status(
+    printer_id: int,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FIRMWARE_READ),
+):
     """
     Get the current status of a firmware upload operation.
 

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

@@ -6,8 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import delete, desc, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
+from backend.app.models.user import User
 from backend.app.schemas.github_backup import (
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
@@ -48,7 +51,10 @@ def _config_to_response(config: GitHubBackupConfig) -> dict:
 
 
 @router.get("/config", response_model=GitHubBackupConfigResponse | None)
-async def get_config(db: AsyncSession = Depends(get_db)):
+async def get_config(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
     """Get the current GitHub backup configuration."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -63,6 +69,7 @@ async def get_config(db: AsyncSession = Depends(get_db)):
 async def save_config(
     config_data: GitHubBackupConfigCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Create or update GitHub backup configuration.
 
@@ -121,6 +128,7 @@ async def save_config(
 async def update_config(
     update_data: GitHubBackupConfigUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Partially update GitHub backup configuration."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -153,7 +161,10 @@ async def update_config(
 
 
 @router.delete("/config")
-async def delete_config(db: AsyncSession = Depends(get_db)):
+async def delete_config(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
     """Delete the GitHub backup configuration and all logs."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -173,6 +184,7 @@ async def delete_config(db: AsyncSession = Depends(get_db)):
 async def test_connection(
     repo_url: str = Query(..., description="GitHub repository URL"),
     token: str = Query(..., description="Personal Access Token"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Test GitHub connection with provided credentials."""
     result = await github_backup_service.test_connection(repo_url, token)
@@ -180,7 +192,10 @@ async def test_connection(
 
 
 @router.post("/test-stored", response_model=GitHubTestConnectionResponse)
-async def test_stored_connection(db: AsyncSession = Depends(get_db)):
+async def test_stored_connection(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
     """Test GitHub connection using stored configuration."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -196,7 +211,10 @@ async def test_stored_connection(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/run", response_model=GitHubBackupTriggerResponse)
-async def trigger_backup(db: AsyncSession = Depends(get_db)):
+async def trigger_backup(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
     """Manually trigger a backup."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -213,7 +231,10 @@ async def trigger_backup(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/status", response_model=GitHubBackupStatus)
-async def get_status(db: AsyncSession = Depends(get_db)):
+async def get_status(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
     """Get current backup status."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -245,6 +266,7 @@ async def get_logs(
     limit: int = Query(default=50, ge=1, le=200),
     offset: int = Query(default=0, ge=0),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Get backup logs."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -282,6 +304,7 @@ async def get_logs(
 async def clear_logs(
     keep_last: int = Query(default=10, ge=0, le=100, description="Number of recent logs to keep"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
 ):
     """Clear backup logs, optionally keeping the most recent entries."""
     result = await db.execute(select(GitHubBackupConfig).limit(1))

+ 10 - 0
backend/app/api/routes/kprofiles.py

@@ -7,9 +7,12 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.schemas.kprofile import (
     KProfile,
     KProfileCreate,
@@ -30,6 +33,7 @@ async def get_kprofiles(
     printer_id: int,
     nozzle_diameter: str = "0.4",
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
     """Get K-profiles from a printer.
 
@@ -78,6 +82,7 @@ async def set_kprofile(
     printer_id: int,
     profile: KProfileCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Create or update a K-profile on the printer.
 
@@ -178,6 +183,7 @@ async def set_kprofiles_batch(
     printer_id: int,
     profiles: list[KProfileCreate],
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Create multiple K-profiles in a single command (for dual-nozzle).
 
@@ -236,6 +242,7 @@ async def delete_kprofile(
     printer_id: int,
     profile: KProfileDelete,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
     """Delete a K-profile from the printer.
 
@@ -278,6 +285,7 @@ async def delete_kprofile(
 async def get_kprofile_notes(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_READ),
 ):
     """Get all K-profile notes for a printer.
 
@@ -305,6 +313,7 @@ async def set_kprofile_note(
     printer_id: int,
     note_data: KProfileNote,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_UPDATE),
 ):
     """Set or update a note for a K-profile.
 
@@ -353,6 +362,7 @@ async def delete_kprofile_note(
     printer_id: int,
     setting_id: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.KPROFILES_DELETE),
 ):
     """Delete a note for a K-profile.
 

+ 88 - 17
backend/app/api/routes/library.py

@@ -16,7 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.core.auth import (
-    require_auth_if_enabled,
     require_ownership_permission,
     require_permission_if_auth_enabled,
 )
@@ -237,7 +236,11 @@ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", "
 
 @router.get("/folders", response_model=list[FolderTreeItem])
 @router.get("/folders/", response_model=list[FolderTreeItem])
-async def list_folders(response: Response, db: AsyncSession = Depends(get_db)):
+async def list_folders(
+    response: Response,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get all folders as a tree structure."""
     # Prevent browser caching of folder list
     response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
@@ -289,7 +292,11 @@ async def list_folders(response: Response, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/folders/by-project/{project_id}", response_model=list[FolderResponse])
-async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get_db)):
+async def get_folders_by_project(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get all folders linked to a specific project."""
     result = await db.execute(
         select(LibraryFolder, Project.name)
@@ -326,7 +333,11 @@ async def get_folders_by_project(project_id: int, db: AsyncSession = Depends(get
 
 
 @router.get("/folders/by-archive/{archive_id}", response_model=list[FolderResponse])
-async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
+async def get_folders_by_archive(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get all folders linked to a specific archive."""
     result = await db.execute(
         select(LibraryFolder, PrintArchive.print_name)
@@ -364,7 +375,11 @@ async def get_folders_by_archive(archive_id: int, db: AsyncSession = Depends(get
 
 @router.post("/folders", response_model=FolderResponse)
 @router.post("/folders/", response_model=FolderResponse)
-async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)):
+async def create_folder(
+    data: FolderCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
+):
     """Create a new folder."""
     # Verify parent exists if specified
     if data.parent_id is not None:
@@ -415,7 +430,11 @@ async def create_folder(data: FolderCreate, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/folders/{folder_id}", response_model=FolderResponse)
-async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
+async def get_folder(
+    folder_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get a folder by ID."""
     result = await db.execute(
         select(LibraryFolder, Project.name, PrintArchive.print_name)
@@ -449,8 +468,17 @@ async def get_folder(folder_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.put("/folders/{folder_id}", response_model=FolderResponse)
-async def update_folder(folder_id: int, data: FolderUpdate, db: AsyncSession = Depends(get_db)):
-    """Update a folder."""
+async def update_folder(
+    folder_id: int,
+    data: FolderUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+):
+    """Update a folder.
+
+    Note: Folders require library:update_all permission since they don't have
+    ownership tracking.
+    """
     result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
     folder = result.scalar_one_or_none()
 
@@ -595,6 +623,7 @@ async def list_files(
     folder_id: int | None = None,
     include_root: bool = True,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
     """List files, optionally filtered by folder.
 
@@ -669,7 +698,7 @@ async def upload_file(
     folder_id: int | None = None,
     generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
 ):
     """Upload a file to the library."""
     try:
@@ -805,7 +834,7 @@ async def extract_zip_file(
     create_folder_from_zip: bool = Query(default=False),
     generate_stl_thumbnails: bool = Query(default=True),
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPLOAD)),
 ):
     """Upload and extract a ZIP file to the library.
 
@@ -1064,9 +1093,13 @@ async def extract_zip_file(
 async def batch_generate_stl_thumbnails(
     request: BatchThumbnailRequest,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
 ):
     """Generate thumbnails for STL files in batch.
 
+    Note: Requires library:update_all permission since this is a batch operation
+    that may affect files owned by different users.
+
     Can generate thumbnails for:
     - Specific file IDs (file_ids)
     - All STL files in a folder (folder_id)
@@ -1188,6 +1221,7 @@ def is_sliced_file(filename: str) -> bool:
 async def add_files_to_queue(
     request: AddToQueueRequest,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
 ):
     """Add library files to the print queue.
 
@@ -1266,6 +1300,7 @@ async def add_files_to_queue(
 async def get_library_file_plates(
     file_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
     """Get available plates from a multi-plate 3MF library file.
 
@@ -1537,6 +1572,7 @@ async def get_library_file_filament_requirements(
     file_id: int,
     plate_id: int | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
 ):
     """Get filament requirements from a library file.
 
@@ -1659,6 +1695,7 @@ async def print_library_file(
     printer_id: int,
     body: FilePrintRequest | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
 ):
     """Print a library file directly.
 
@@ -1846,7 +1883,11 @@ async def print_library_file(
 
 
 @router.get("/files/{file_id}", response_model=FileResponseSchema)
-async def get_file(file_id: int, db: AsyncSession = Depends(get_db)):
+async def get_file(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get a file by ID with full details."""
     result = await db.execute(
         select(LibraryFile).options(selectinload(LibraryFile.created_by)).where(LibraryFile.id == file_id)
@@ -2021,7 +2062,11 @@ async def delete_file(
 
 
 @router.get("/files/{file_id}/download")
-async def download_file(file_id: int, db: AsyncSession = Depends(get_db)):
+async def download_file(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Download a file."""
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
@@ -2068,7 +2113,11 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/files/{file_id}/gcode")
-async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
+async def get_gcode(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get gcode for a file (for preview)."""
     result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
     file = result.scalar_one_or_none()
@@ -2106,8 +2155,22 @@ async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/files/move")
-async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)):
-    """Move multiple files to a folder."""
+async def move_files(
+    data: FileMoveRequest,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+):
+    """Move multiple files to a folder.
+
+    Files not owned by the user are skipped (unless user has *_all permission).
+    """
+    user, can_modify_all = auth_result
+
     # Verify folder exists if specified
     if data.folder_id is not None:
         folder_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == data.folder_id))
@@ -2116,14 +2179,19 @@ async def move_files(data: FileMoveRequest, db: AsyncSession = Depends(get_db)):
 
     # Update files
     moved = 0
+    skipped = 0
     for file_id in data.file_ids:
         result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
         file = result.scalar_one_or_none()
         if file:
+            # Ownership check
+            if not can_modify_all and file.created_by_id != user.id:
+                skipped += 1
+                continue
             file.folder_id = data.folder_id
             moved += 1
 
-    return {"status": "success", "moved": moved}
+    return {"status": "success", "moved": moved, "skipped": skipped}
 
 
 @router.post("/bulk-delete", response_model=BulkDeleteResponse)
@@ -2193,7 +2261,10 @@ async def bulk_delete(
 
 
 @router.get("/stats")
-async def get_library_stats(db: AsyncSession = Depends(get_db)):
+async def get_library_stats(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
     """Get library statistics."""
     # Total files
     total_files_result = await db.execute(select(func.count(LibraryFile.id)))

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

@@ -8,9 +8,12 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.printer import Printer
+from backend.app.models.user import User
 from backend.app.schemas.maintenance import (
     MaintenanceHistoryResponse,
     MaintenanceStatus,
@@ -114,7 +117,10 @@ async def ensure_default_types(db: AsyncSession) -> None:
 
 
 @router.get("/types", response_model=list[MaintenanceTypeResponse])
-async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
+async def get_maintenance_types(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+):
     """Get all maintenance types."""
     await ensure_default_types(db)
     result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name))
@@ -125,6 +131,7 @@ async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
 async def create_maintenance_type(
     data: MaintenanceTypeCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
     """Create a custom maintenance type."""
     new_type = MaintenanceType(
@@ -146,6 +153,7 @@ async def update_maintenance_type(
     type_id: int,
     data: MaintenanceTypeUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Update a maintenance type."""
     result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
@@ -166,6 +174,7 @@ async def update_maintenance_type(
 async def delete_maintenance_type(
     type_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
     """Delete a custom maintenance type."""
     result = await db.execute(select(MaintenanceType).where(MaintenanceType.id == type_id))
@@ -331,13 +340,17 @@ async def _get_printer_maintenance_internal(
 async def get_printer_maintenance(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
     """Get maintenance overview for a specific printer."""
     return await _get_printer_maintenance_internal(printer_id, db, commit=True)
 
 
 @router.get("/overview", response_model=list[PrinterMaintenanceOverview])
-async def get_all_maintenance_overview(db: AsyncSession = Depends(get_db)):
+async def get_all_maintenance_overview(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+):
     """Get maintenance overview for all active printers."""
     await ensure_default_types(db)
 
@@ -361,6 +374,7 @@ async def update_printer_maintenance(
     item_id: int,
     data: PrinterMaintenanceUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Update a printer maintenance item (e.g., custom interval, enabled)."""
     result = await db.execute(
@@ -386,6 +400,7 @@ async def assign_maintenance_type(
     printer_id: int,
     type_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_CREATE),
 ):
     """Assign a maintenance type to a specific printer (for custom types)."""
     # Verify printer exists
@@ -438,6 +453,7 @@ async def assign_maintenance_type(
 async def remove_maintenance_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
 ):
     """Remove a maintenance item (unassign a custom type from a printer)."""
     result = await db.execute(
@@ -464,6 +480,7 @@ async def perform_maintenance(
     item_id: int,
     data: PerformMaintenanceRequest,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Mark maintenance as performed (reset the counter)."""
     result = await db.execute(
@@ -541,6 +558,7 @@ async def perform_maintenance(
 async def get_maintenance_history(
     item_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
 ):
     """Get maintenance history for a specific item."""
     result = await db.execute(
@@ -552,7 +570,10 @@ async def get_maintenance_history(
 
 
 @router.get("/summary")
-async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
+async def get_maintenance_summary(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_READ),
+):
     """Get a summary of maintenance status across all printers."""
     await ensure_default_types(db)
 
@@ -589,6 +610,7 @@ async def set_printer_hours(
     printer_id: int,
     total_hours: float,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_UPDATE),
 ):
     """Set the total print hours for a printer (adjusts offset to match).
 

+ 4 - 2
backend/app/api/routes/metrics.py

@@ -362,11 +362,13 @@ async def get_metrics(
             )
             lines.append(f"bambuddy_printer_prints_total{labels} {count}")
 
-    # Total filament used
+    # Total filament used (multiply by quantity to account for multiple items printed)
     lines.append("")
     lines.append("# HELP bambuddy_filament_used_grams Total filament used in grams")
     lines.append("# TYPE bambuddy_filament_used_grams counter")
-    result = await db.execute(select(func.coalesce(func.sum(PrintArchive.filament_used_grams), 0)))
+    result = await db.execute(
+        select(func.coalesce(func.sum(PrintArchive.filament_used_grams * func.coalesce(PrintArchive.quantity, 1)), 0))
+    )
     total_filament = result.scalar() or 0
     lines.append(f"bambuddy_filament_used_grams {total_filament:.1f}")
 

+ 25 - 5
backend/app/api/routes/notification_templates.py

@@ -4,8 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
+from backend.app.models.user import User
 from backend.app.schemas.notification_template import (
     EVENT_VARIABLES,
     SAMPLE_DATA,
@@ -45,14 +48,19 @@ EVENT_NAMES = {
 
 @router.get("", response_model=list[NotificationTemplateResponse])
 @router.get("/", response_model=list[NotificationTemplateResponse])
-async def get_templates(db: AsyncSession = Depends(get_db)):
+async def get_templates(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
+):
     """Get all notification templates."""
     result = await db.execute(select(NotificationTemplate).order_by(NotificationTemplate.id))
     return result.scalars().all()
 
 
 @router.get("/variables", response_model=list[EventVariablesResponse])
-async def get_variables():
+async def get_variables(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
+):
     """Get available variables for each event type."""
     return [
         EventVariablesResponse(
@@ -65,7 +73,11 @@ async def get_variables():
 
 
 @router.get("/{template_id}", response_model=NotificationTemplateResponse)
-async def get_template(template_id: int, db: AsyncSession = Depends(get_db)):
+async def get_template(
+    template_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
+):
     """Get a single notification template."""
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
@@ -79,6 +91,7 @@ async def update_template(
     template_id: int,
     update: NotificationTemplateUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
 ):
     """Update a notification template."""
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
@@ -101,7 +114,11 @@ async def update_template(
 
 
 @router.post("/{template_id}/reset", response_model=NotificationTemplateResponse)
-async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
+async def reset_template(
+    template_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_UPDATE),
+):
     """Reset a notification template to its default values."""
     result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.id == template_id))
     template = result.scalar_one_or_none()
@@ -129,7 +146,10 @@ async def reset_template(template_id: int, db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/preview", response_model=TemplatePreviewResponse)
-async def preview_template(request: TemplatePreviewRequest):
+async def preview_template(
+    request: TemplatePreviewRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATION_TEMPLATES_READ),
+):
     """Preview a template with sample data."""
     sample = SAMPLE_DATA.get(request.event_type, {})
 

+ 20 - 2
backend/app/api/routes/notifications.py

@@ -8,8 +8,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import delete, desc, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.notification import NotificationLog, NotificationProvider
+from backend.app.models.user import User
 from backend.app.schemas.notification import (
     NotificationLogResponse,
     NotificationLogStats,
@@ -86,7 +89,10 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
 
 
 @router.get("/", response_model=list[NotificationProviderResponse])
-async def list_notification_providers(db: AsyncSession = Depends(get_db)):
+async def list_notification_providers(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
+):
     """List all notification providers."""
     result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
     providers = result.scalars().all()
@@ -98,6 +104,7 @@ async def list_notification_providers(db: AsyncSession = Depends(get_db)):
 async def create_notification_provider(
     provider_data: NotificationProviderCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
 ):
     """Create a new notification provider."""
     provider = NotificationProvider(
@@ -153,6 +160,7 @@ async def create_notification_provider(
 async def test_notification_config(
     test_request: NotificationTestRequest,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
 ):
     """Test notification configuration before saving."""
     success, message = await notification_service.send_test_notification(
@@ -163,7 +171,10 @@ async def test_notification_config(
 
 
 @router.post("/test-all")
-async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
+async def test_all_notification_providers(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
+):
     """Send a test notification to all enabled providers."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
     providers = result.scalars().all()
@@ -222,6 +233,7 @@ async def get_notification_logs(
     success: bool | None = Query(default=None),
     days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
     """Get notification logs with optional filters."""
     query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
@@ -278,6 +290,7 @@ async def get_notification_logs(
 async def get_notification_log_stats(
     days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
     """Get notification log statistics."""
     cutoff = datetime.utcnow() - timedelta(days=days)
@@ -323,6 +336,7 @@ async def get_notification_log_stats(
 async def clear_notification_logs(
     older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
 ):
     """Clear old notification logs."""
     cutoff = datetime.utcnow() - timedelta(days=older_than_days)
@@ -345,6 +359,7 @@ async def clear_notification_logs(
 async def get_notification_provider(
     provider_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
 ):
     """Get a specific notification provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
@@ -361,6 +376,7 @@ async def update_notification_provider(
     provider_id: int,
     update_data: NotificationProviderUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
 ):
     """Update a notification provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
@@ -392,6 +408,7 @@ async def update_notification_provider(
 async def delete_notification_provider(
     provider_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
 ):
     """Delete a notification provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
@@ -413,6 +430,7 @@ async def delete_notification_provider(
 async def test_notification_provider(
     provider_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
 ):
     """Send a test notification using an existing provider."""
     result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))

+ 22 - 4
backend/app/api/routes/pending_uploads.py

@@ -8,8 +8,11 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.pending_upload import PendingUpload
+from backend.app.models.user import User
 from backend.app.services.archive import ArchiveService
 
 router = APIRouter(prefix="/pending-uploads", tags=["pending-uploads"])
@@ -41,7 +44,10 @@ class PendingUploadResponse(BaseModel):
 
 
 @router.get("/", response_model=list[PendingUploadResponse])
-async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
+async def list_pending_uploads(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
     """List all pending uploads."""
     result = await db.execute(
         select(PendingUpload).where(PendingUpload.status == "pending").order_by(PendingUpload.uploaded_at.desc())
@@ -51,7 +57,10 @@ async def list_pending_uploads(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/count")
-async def get_pending_count(db: AsyncSession = Depends(get_db)):
+async def get_pending_count(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
     """Get count of pending uploads."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     count = len(result.scalars().all())
@@ -64,7 +73,10 @@ async def get_pending_count(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/archive-all")
-async def archive_all_pending(db: AsyncSession = Depends(get_db)):
+async def archive_all_pending(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+):
     """Archive all pending uploads."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     pending_uploads = result.scalars().all()
@@ -117,7 +129,10 @@ async def archive_all_pending(db: AsyncSession = Depends(get_db)):
 
 
 @router.delete("/discard-all")
-async def discard_all_pending(db: AsyncSession = Depends(get_db)):
+async def discard_all_pending(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
+):
     """Discard all pending uploads."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending"))
     pending_uploads = result.scalars().all()
@@ -144,6 +159,7 @@ async def discard_all_pending(db: AsyncSession = Depends(get_db)):
 async def get_pending_upload(
     upload_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
 ):
     """Get a specific pending upload."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
@@ -160,6 +176,7 @@ async def archive_pending_upload(
     upload_id: int,
     request: ArchiveRequest = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
     """Archive a pending upload."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))
@@ -227,6 +244,7 @@ async def archive_pending_upload(
 async def discard_pending_upload(
     upload_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_DELETE_ALL),
 ):
     """Discard a pending upload without archiving."""
     result = await db.execute(select(PendingUpload).where(PendingUpload.id == upload_id))

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

@@ -12,7 +12,7 @@ from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
-from backend.app.core.auth import require_auth_if_enabled, require_ownership_permission
+from backend.app.core.auth import RequirePermissionIfAuthEnabled, require_ownership_permission
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -121,6 +121,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "id": item.id,
         "printer_id": item.printer_id,
         "target_model": item.target_model,
+        "target_location": item.target_location,
         "required_filament_types": required_filament_types_parsed,
         "waiting_reason": item.waiting_reason,
         "archive_id": item.archive_id,
@@ -172,6 +173,7 @@ async def list_queue(
     printer_id: int | None = Query(None, description="Filter by printer (-1 for unassigned)"),
     status: str | None = Query(None, description="Filter by status"),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
 ):
     """List all queue items, optionally filtered by printer or status."""
     query = (
@@ -203,7 +205,7 @@ async def list_queue(
 async def add_to_queue(
     data: PrintQueueItemCreate,
     db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_auth_if_enabled),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
     """Add an item to the print queue."""
     # Normalize target_model (e.g., "Bambu Lab X1E" / "C13" -> "X1E")
@@ -289,6 +291,7 @@ async def add_to_queue(
     item = PrintQueueItem(
         printer_id=data.printer_id,
         target_model=target_model_norm,
+        target_location=data.target_location,
         required_filament_types=required_filament_types,
         archive_id=data.archive_id,
         library_file_id=data.library_file_id,
@@ -423,7 +426,11 @@ async def bulk_update_queue_items(
 
 
 @router.get("/{item_id}", response_model=PrintQueueItemResponse)
-async def get_queue_item(item_id: int, db: AsyncSession = Depends(get_db)):
+async def get_queue_item(
+    item_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_READ),
+):
     """Get a specific queue item."""
     result = await db.execute(
         select(PrintQueueItem)
@@ -551,6 +558,7 @@ async def delete_queue_item(
 async def reorder_queue(
     data: PrintQueueReorder,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
 ):
     """Bulk update positions for queue items."""
     for reorder_item in data.items:
@@ -603,6 +611,7 @@ async def cancel_queue_item(
 async def stop_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
 ):
     """Stop an actively printing queue item."""
     import asyncio
@@ -673,6 +682,7 @@ async def stop_queue_item(
 async def start_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
 ):
     """Manually start a staged (manual_start) queue item.
 

+ 6 - 3
backend/app/api/routes/printers.py

@@ -672,10 +672,11 @@ async def get_printer_cover(
         # Extract thumbnail from 3MF (which is a ZIP file)
         try:
             zf = zipfile.ZipFile(temp_path, "r")
-        except zipfile.BadZipFile as e:
-            raise HTTPException(500, f"Downloaded file is not a valid 3MF/ZIP: {e}")
+        except zipfile.BadZipFile:
+            raise HTTPException(500, "Downloaded file is not a valid 3MF/ZIP archive")
         except Exception as e:
-            raise HTTPException(500, f"Failed to open 3MF file: {e}")
+            logger.error(f"Failed to open 3MF file: {e}", exc_info=True)
+            raise HTTPException(500, "Failed to open 3MF file. Check server logs for details.")
 
         try:
             # Try common thumbnail paths in 3MF files
@@ -1676,6 +1677,7 @@ async def reset_ams_slot(
     ams_id: int,
     tray_id: int,
     db: AsyncSession = Depends(get_db),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """Reset an AMS slot to empty/unconfigured state.
 
@@ -1718,6 +1720,7 @@ async def reset_ams_slot(
 async def debug_simulate_print_complete(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
     """DEBUG: Simulate print completion to test freeze behavior.
 

+ 27 - 0
backend/app/api/routes/projects.py

@@ -14,13 +14,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.library import get_library_dir
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.project_bom import ProjectBOMItem
+from backend.app.models.user import User
 from backend.app.schemas.project import (
     ArchivePreview,
     BatchAddArchives,
@@ -154,6 +157,7 @@ async def compute_project_stats(
 async def list_projects(
     status: str | None = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List all projects with basic stats."""
     query = select(Project)
@@ -258,6 +262,7 @@ async def list_projects(
 async def create_project(
     data: ProjectCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Create a new project."""
     # Verify parent exists if specified
@@ -319,6 +324,7 @@ async def create_project(
 @router.get("/templates", response_model=list[ProjectListResponse])
 async def list_templates(
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List all project templates."""
     result = await db.execute(select(Project).where(Project.is_template.is_(True)).order_by(Project.name))
@@ -356,6 +362,7 @@ async def create_project_from_template(
     template_id: int,
     name: str = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Create a new project from a template."""
     result = await db.execute(select(Project).where(Project.id == template_id))
@@ -470,6 +477,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
 async def get_project(
     project_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Get a project by ID with detailed stats."""
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -519,6 +527,7 @@ async def update_project(
     project_id: int,
     data: ProjectUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Update a project."""
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -609,6 +618,7 @@ async def update_project(
 async def delete_project(
     project_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_DELETE),
 ):
     """Delete a project. Archives and queue items will have project_id set to NULL."""
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -628,6 +638,7 @@ async def list_project_archives(
     limit: int = 100,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List archives in a project."""
     # Verify project exists
@@ -657,6 +668,7 @@ async def list_project_archives(
 async def list_project_queue(
     project_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List queue items in a project."""
     # Verify project exists
@@ -677,6 +689,7 @@ async def add_archives_to_project(
     project_id: int,
     data: BatchAddArchives,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Batch add archives to a project."""
     # Verify project exists
@@ -701,6 +714,7 @@ async def add_queue_items_to_project(
     project_id: int,
     data: BatchAddQueueItems,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Batch add queue items to a project."""
     # Verify project exists
@@ -725,6 +739,7 @@ async def remove_archives_from_project(
     project_id: int,
     data: BatchAddArchives,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Remove archives from a project (sets project_id to NULL)."""
     updated = 0
@@ -811,6 +826,7 @@ async def upload_attachment(
     project_id: int,
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Upload an attachment to a project."""
     logger.info(f"=== UPLOAD START: {file.filename} for project {project_id} ===")
@@ -888,6 +904,7 @@ async def download_attachment(
     project_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Download an attachment from a project."""
     # Verify project exists
@@ -919,6 +936,7 @@ async def delete_attachment(
     project_id: int,
     filename: str,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Delete an attachment from a project."""
     # Verify project exists
@@ -962,6 +980,7 @@ async def delete_attachment(
 async def list_bom_items(
     project_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """List all BOM items for a project."""
     # Verify project exists
@@ -1013,6 +1032,7 @@ async def create_bom_item(
     project_id: int,
     data: BOMItemCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Add a BOM item to a project."""
     # Verify project exists
@@ -1072,6 +1092,7 @@ async def update_bom_item(
     item_id: int,
     data: BOMItemUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Update a BOM item."""
     result = await db.execute(
@@ -1135,6 +1156,7 @@ async def delete_bom_item(
     project_id: int,
     item_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_UPDATE),
 ):
     """Delete a BOM item."""
     result = await db.execute(
@@ -1157,6 +1179,7 @@ async def delete_bom_item(
 async def create_template_from_project(
     project_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Create a template from an existing project."""
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -1238,6 +1261,7 @@ async def get_project_timeline(
     project_id: int,
     limit: int = 50,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Get timeline of events for a project."""
     # Verify project exists
@@ -1338,6 +1362,7 @@ async def export_project(
     project_id: int,
     format: str = "zip",  # "zip" (with files) or "json" (metadata only)
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
 ):
     """Export a project. Use format=zip (default) for full export with files, or format=json for metadata only."""
     result = await db.execute(select(Project).where(Project.id == project_id))
@@ -1458,6 +1483,7 @@ async def export_project(
 async def import_project(
     data: ProjectImport,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Import a project with optional BOM items and linked folders."""
     # Create the project
@@ -1551,6 +1577,7 @@ async def import_project(
 async def import_project_file(
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_CREATE),
 ):
     """Import a project from a ZIP or JSON file."""
     if not file.filename:

+ 236 - 94
backend/app/api/routes/settings.py

@@ -1,4 +1,5 @@
 import io
+import logging
 import zipfile
 from datetime import datetime
 from pathlib import Path
@@ -8,11 +9,16 @@ from fastapi.responses import JSONResponse, StreamingResponse
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.settings import Settings
+from backend.app.models.user import User
 from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
 
+logger = logging.getLogger(__name__)
+
 router = APIRouter(prefix="/settings", tags=["settings"])
 
 # Default settings
@@ -39,7 +45,10 @@ async def set_setting(db: AsyncSession, key: str, value: str) -> None:
 
 @router.get("", response_model=AppSettings)
 @router.get("/", response_model=AppSettings)
-async def get_settings(db: AsyncSession = Depends(get_db)):
+async def get_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get all application settings."""
     settings_dict = DEFAULT_SETTINGS.model_dump()
 
@@ -96,6 +105,7 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
 async def update_settings(
     settings_update: AppSettingsUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
@@ -153,13 +163,17 @@ async def update_settings(
 async def patch_settings(
     settings_update: AppSettingsUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Partially update application settings (same as PUT, for REST compatibility)."""
-    return await update_settings(settings_update, db)
+    return await update_settings(settings_update, db, _)
 
 
 @router.post("/reset", response_model=AppSettings)
-async def reset_settings(db: AsyncSession = Depends(get_db)):
+async def reset_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Reset all settings to defaults."""
     # Delete all settings
     result = await db.execute(select(Settings))
@@ -185,7 +199,10 @@ async def check_ffmpeg():
 
 
 @router.get("/spoolman")
-async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
+async def get_spoolman_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get Spoolman integration settings."""
     spoolman_enabled = await get_setting(db, "spoolman_enabled") or "false"
     spoolman_url = await get_setting(db, "spoolman_url") or ""
@@ -202,6 +219,7 @@ async def get_spoolman_settings(db: AsyncSession = Depends(get_db)):
 async def update_spoolman_settings(
     settings: dict,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update Spoolman integration settings."""
     if "spoolman_enabled" in settings:
@@ -219,7 +237,10 @@ async def update_spoolman_settings(
 
 
 @router.get("/backup")
-async def create_backup(db: AsyncSession = Depends(get_db)):
+async def create_backup(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_BACKUP),
+):
     """Create a complete backup (database + all files) as a ZIP.
 
     This is a simplified backup that includes the entire SQLite database
@@ -232,47 +253,61 @@ async def create_backup(db: AsyncSession = Depends(get_db)):
 
     from backend.app.core.database import engine
 
-    base_dir = app_settings.base_dir
-    db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
-
-    with tempfile.TemporaryDirectory() as temp_dir:
-        temp_path = Path(temp_dir)
-
-        # 1. Checkpoint WAL to ensure all data is in main db file
-        async with engine.begin() as conn:
-            await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
-
-        # 2. Copy database file
-        shutil.copy2(db_path, temp_path / "bambuddy.db")
-
-        # 3. Copy data directories (if they exist)
-        dirs_to_backup = [
-            ("archive", base_dir / "archive"),
-            ("virtual_printer", base_dir / "virtual_printer"),
-            ("plate_calibration", app_settings.plate_calibration_dir),
-            ("icons", base_dir / "icons"),
-            ("projects", base_dir / "projects"),
-        ]
-
-        for name, src_dir in dirs_to_backup:
-            if src_dir.exists() and any(src_dir.iterdir()):
-                shutil.copytree(src_dir, temp_path / name)
-
-        # 4. Create ZIP
-        zip_buffer = io.BytesIO()
-        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
-            for file_path in temp_path.rglob("*"):
-                if file_path.is_file():
-                    arcname = file_path.relative_to(temp_path)
-                    zf.write(file_path, arcname)
-
-        zip_buffer.seek(0)
-        filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
-
-        return StreamingResponse(
-            zip_buffer,
-            media_type="application/zip",
-            headers={"Content-Disposition": f"attachment; filename={filename}"},
+    try:
+        base_dir = app_settings.base_dir
+        db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
+
+        with tempfile.TemporaryDirectory() as temp_dir:
+            temp_path = Path(temp_dir)
+
+            # 1. Checkpoint WAL to ensure all data is in main db file
+            async with engine.begin() as conn:
+                await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)"))
+
+            # 2. Copy database file
+            shutil.copy2(db_path, temp_path / "bambuddy.db")
+
+            # 3. Copy data directories (if they exist)
+            dirs_to_backup = [
+                ("archive", base_dir / "archive"),
+                ("virtual_printer", base_dir / "virtual_printer"),
+                ("plate_calibration", app_settings.plate_calibration_dir),
+                ("icons", base_dir / "icons"),
+                ("projects", base_dir / "projects"),
+            ]
+
+            for name, src_dir in dirs_to_backup:
+                if src_dir.exists() and any(src_dir.iterdir()):
+                    try:
+                        shutil.copytree(src_dir, temp_path / name)
+                    except shutil.Error as e:
+                        # Some files may have restricted permissions (e.g., SSL keys)
+                        # Log the error but continue with partial backup
+                        logger.warning(f"Some files in {name} could not be copied: {e}")
+                    except PermissionError as e:
+                        logger.warning(f"Permission denied copying {name}: {e}")
+
+            # 4. Create ZIP
+            zip_buffer = io.BytesIO()
+            with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+                for file_path in temp_path.rglob("*"):
+                    if file_path.is_file():
+                        arcname = file_path.relative_to(temp_path)
+                        zf.write(file_path, arcname)
+
+            zip_buffer.seek(0)
+            filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip"
+
+            return StreamingResponse(
+                zip_buffer,
+                media_type="application/zip",
+                headers={"Content-Disposition": f"attachment; filename={filename}"},
+            )
+    except Exception as e:
+        logger.error(f"Backup failed: {e}", exc_info=True)
+        return JSONResponse(
+            status_code=500,
+            content={"success": False, "message": "Backup failed. Check server logs for details."},
         )
 
 
@@ -280,6 +315,7 @@ async def create_backup(db: AsyncSession = Depends(get_db)):
 async def restore_backup(
     file: UploadFile = File(...),
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_RESTORE),
 ):
     """Restore from a complete backup ZIP.
 
@@ -292,6 +328,7 @@ async def restore_backup(
     from fastapi import HTTPException
 
     from backend.app.core.database import close_all_connections
+    from backend.app.services.virtual_printer import virtual_printer_manager
 
     base_dir = app_settings.base_dir
     db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", ""))
@@ -317,39 +354,90 @@ async def restore_backup(
         if not backup_db.exists():
             raise HTTPException(400, "Invalid backup: missing bambuddy.db")
 
-        # 3. Close current database connections
-        await close_all_connections()
-
-        # 4. Replace database
-        shutil.copy2(backup_db, db_path)
-
-        # 5. Replace data directories
-        dirs_to_restore = [
-            ("archive", base_dir / "archive"),
-            ("virtual_printer", base_dir / "virtual_printer"),
-            ("plate_calibration", app_settings.plate_calibration_dir),
-            ("icons", base_dir / "icons"),
-            ("projects", base_dir / "projects"),
-        ]
-
-        for name, dest_dir in dirs_to_restore:
-            src_dir = temp_path / name
-            if src_dir.exists():
-                if dest_dir.exists():
-                    shutil.rmtree(dest_dir)
-                shutil.copytree(src_dir, dest_dir)
-
-        # 6. Note: Database connection will be reinitialized on restart
-        # The application should be restarted after restore
+        try:
+            import asyncio
+
+            # 3. Stop virtual printer if running (releases file locks)
+            try:
+                if virtual_printer_manager.is_enabled:
+                    logger.info("Stopping virtual printer for restore...")
+                    await virtual_printer_manager.configure(enabled=False)
+                    # Give it time to fully release file handles
+                    await asyncio.sleep(1)
+            except Exception as e:
+                logger.warning(f"Failed to stop virtual printer: {e}")
+
+            # 4. Close current database connections
+            logger.info("Closing database connections...")
+            await close_all_connections()
+
+            # 5. Replace database
+            logger.info("Restoring database from backup...")
+            shutil.copy2(backup_db, db_path)
+
+            # 6. Replace data directories
+            # For Docker compatibility: clear contents then copy (don't delete mount points)
+            dirs_to_restore = [
+                ("archive", base_dir / "archive"),
+                ("virtual_printer", base_dir / "virtual_printer"),
+                ("plate_calibration", app_settings.plate_calibration_dir),
+                ("icons", base_dir / "icons"),
+                ("projects", base_dir / "projects"),
+            ]
+
+            skipped_dirs = []
+            for name, dest_dir in dirs_to_restore:
+                src_dir = temp_path / name
+                if src_dir.exists():
+                    logger.info(f"Restoring {name} directory...")
+                    try:
+                        # Clear destination contents (not the dir itself - may be Docker mount)
+                        if dest_dir.exists():
+                            for item in dest_dir.iterdir():
+                                try:
+                                    if item.is_dir():
+                                        shutil.rmtree(item)
+                                    else:
+                                        item.unlink()
+                                except OSError as e:
+                                    logger.warning(f"Could not delete {item}: {e}")
+                        else:
+                            dest_dir.mkdir(parents=True, exist_ok=True)
+                        # Copy contents from backup
+                        for item in src_dir.iterdir():
+                            dest_item = dest_dir / item.name
+                            if item.is_dir():
+                                shutil.copytree(item, dest_item)
+                            else:
+                                shutil.copy2(item, dest_item)
+                    except OSError as e:
+                        logger.warning(f"Could not restore {name} directory: {e}")
+                        skipped_dirs.append(name)
+
+            # 7. Note: Virtual printer and database will be reinitialized on restart
+            # Do NOT try to restart services here - the database session is closed
+
+            logger.info("Restore complete - restart required")
+            message = "Backup restored successfully. Please restart Bambuddy for changes to take effect."
+            if skipped_dirs:
+                message += f" Note: Some directories could not be restored ({', '.join(skipped_dirs)})."
+            return {
+                "success": True,
+                "message": message,
+            }
 
-        return {
-            "success": True,
-            "message": "Backup restored successfully. Please restart Bambuddy for changes to take effect.",
-        }
+        except Exception as e:
+            logger.error(f"Restore failed: {e}", exc_info=True)
+            return JSONResponse(
+                status_code=500,
+                content={"success": False, "message": "Restore failed. Check server logs for details."},
+            )
 
 
 @router.get("/virtual-printer/models")
-async def get_virtual_printer_models():
+async def get_virtual_printer_models(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get available virtual printer models."""
     from backend.app.services.virtual_printer import (
         DEFAULT_VIRTUAL_PRINTER_MODEL,
@@ -363,7 +451,10 @@ async def get_virtual_printer_models():
 
 
 @router.get("/virtual-printer")
-async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
+async def get_virtual_printer_settings(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get virtual printer settings and status."""
     from backend.app.services.virtual_printer import (
         DEFAULT_VIRTUAL_PRINTER_MODEL,
@@ -374,12 +465,14 @@ async def get_virtual_printer_settings(db: AsyncSession = Depends(get_db)):
     access_code = await get_setting(db, "virtual_printer_access_code")
     mode = await get_setting(db, "virtual_printer_mode")
     model = await get_setting(db, "virtual_printer_model")
+    target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
 
     return {
         "enabled": enabled == "true" if enabled else False,
         "access_code_set": bool(access_code),
         "mode": mode or "immediate",
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
+        "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "status": virtual_printer_manager.get_status(),
     }
 
@@ -390,9 +483,14 @@ async def update_virtual_printer_settings(
     access_code: str = None,
     mode: str = None,
     model: str = None,
+    target_printer_id: int = None,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update virtual printer settings and restart services if needed."""
+    from sqlalchemy import select
+
+    from backend.app.models.printer import Printer
     from backend.app.services.virtual_printer import (
         DEFAULT_VIRTUAL_PRINTER_MODEL,
         VIRTUAL_PRINTER_MODELS,
@@ -404,20 +502,24 @@ async def update_virtual_printer_settings(
     current_access_code = await get_setting(db, "virtual_printer_access_code") or ""
     current_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
     current_model = await get_setting(db, "virtual_printer_model") or DEFAULT_VIRTUAL_PRINTER_MODEL
+    current_target_id_str = await get_setting(db, "virtual_printer_target_printer_id")
+    current_target_id = int(current_target_id_str) if current_target_id_str else None
 
     # Apply updates
     new_enabled = enabled if enabled is not None else current_enabled
     new_access_code = access_code if access_code is not None else current_access_code
     new_mode = mode if mode is not None else current_mode
     new_model = model if model is not None else current_model
+    new_target_id = target_printer_id if target_printer_id is not None else current_target_id
 
     # Validate mode
     # "review" is the new name for "queue" (pending review before archiving)
     # "print_queue" archives and adds to print queue (unassigned)
-    if new_mode not in ("immediate", "queue", "review", "print_queue"):
+    # "proxy" is transparent TCP proxy to a real printer
+    if new_mode not in ("immediate", "queue", "review", "print_queue", "proxy"):
         return JSONResponse(
             status_code=400,
-            content={"detail": "Mode must be 'immediate', 'review', or 'print_queue'"},
+            content={"detail": "Mode must be 'immediate', 'review', 'print_queue', or 'proxy'"},
         )
     # Normalize legacy "queue" to "review" for storage
     if new_mode == "queue":
@@ -430,19 +532,51 @@ async def update_virtual_printer_settings(
             content={"detail": f"Invalid model. Must be one of: {', '.join(VIRTUAL_PRINTER_MODELS.keys())}"},
         )
 
-    # Validate access code when enabling
-    if new_enabled and not new_access_code:
-        return JSONResponse(
-            status_code=400,
-            content={"detail": "Access code is required when enabling virtual printer"},
-        )
-
-    # Validate access code length (Bambu Studio requires exactly 8 characters)
-    if access_code is not None and len(access_code) != 8:
-        return JSONResponse(
-            status_code=400,
-            content={"detail": "Access code must be exactly 8 characters"},
-        )
+    # Mode-specific validation and printer lookup
+    target_printer_ip = ""
+    target_printer_serial = ""
+    if new_mode == "proxy":
+        # Proxy mode requires target printer when enabling
+        if new_enabled and not new_target_id:
+            # If just switching to proxy mode (not explicitly enabling), auto-disable
+            if enabled is None:
+                new_enabled = False
+            else:
+                return JSONResponse(
+                    status_code=400,
+                    content={"detail": "Target printer is required for proxy mode"},
+                )
+
+        # Look up printer IP and serial if we have a target
+        if new_target_id:
+            result = await db.execute(select(Printer).where(Printer.id == new_target_id))
+            printer = result.scalar_one_or_none()
+            if not printer:
+                return JSONResponse(
+                    status_code=400,
+                    content={"detail": f"Printer with ID {new_target_id} not found"},
+                )
+            target_printer_ip = printer.ip_address
+            target_printer_serial = printer.serial_number
+        # Access code not required for proxy mode
+    else:
+        # Non-proxy modes require access code when enabling
+        if new_enabled and not new_access_code:
+            # If just switching modes (not explicitly enabling), auto-disable
+            if enabled is None:
+                new_enabled = False
+            else:
+                return JSONResponse(
+                    status_code=400,
+                    content={"detail": "Access code is required when enabling virtual printer"},
+                )
+
+        # Validate access code length (Bambu Studio requires exactly 8 characters)
+        if access_code is not None and access_code and len(access_code) != 8:
+            return JSONResponse(
+                status_code=400,
+                content={"detail": "Access code must be exactly 8 characters"},
+            )
 
     # Save settings
     await set_setting(db, "virtual_printer_enabled", "true" if new_enabled else "false")
@@ -451,6 +585,8 @@ async def update_virtual_printer_settings(
     await set_setting(db, "virtual_printer_mode", new_mode)
     if model is not None:
         await set_setting(db, "virtual_printer_model", model)
+    if target_printer_id is not None:
+        await set_setting(db, "virtual_printer_target_printer_id", str(target_printer_id))
     await db.commit()
     db.expire_all()
 
@@ -461,16 +597,20 @@ async def update_virtual_printer_settings(
             access_code=new_access_code,
             mode=new_mode,
             model=new_model,
+            target_printer_ip=target_printer_ip,
+            target_printer_serial=target_printer_serial,
         )
     except ValueError as e:
+        logger.warning(f"Virtual printer configuration validation error: {e}")
         return JSONResponse(
             status_code=400,
-            content={"detail": str(e)},
+            content={"detail": "Invalid virtual printer configuration. Check the provided values."},
         )
     except Exception as e:
+        logger.error(f"Failed to configure virtual printer: {e}", exc_info=True)
         return JSONResponse(
             status_code=500,
-            content={"detail": f"Failed to configure virtual printer: {e}"},
+            content={"detail": "Failed to configure virtual printer. Check server logs for details."},
         )
 
     return await get_virtual_printer_settings(db)
@@ -482,7 +622,9 @@ async def update_virtual_printer_settings(
 
 
 @router.get("/mqtt/status")
-async def get_mqtt_status():
+async def get_mqtt_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get MQTT relay connection status."""
     from backend.app.services.mqtt_relay import mqtt_relay
 

+ 94 - 57
backend/app/api/routes/smart_plugs.py

@@ -9,9 +9,12 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.api.routes.settings import get_setting
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 from backend.app.schemas.smart_plug import (
     HAEntity,
     HASensorEntity,
@@ -38,7 +41,10 @@ router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
 
 
 @router.get("/", response_model=list[SmartPlugResponse])
-async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
+async def list_smart_plugs(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """List all smart plugs."""
     result = await db.execute(select(SmartPlug).order_by(SmartPlug.name))
     return list(result.scalars().all())
@@ -48,6 +54,7 @@ async def list_smart_plugs(db: AsyncSession = Depends(get_db)):
 async def create_smart_plug(
     data: SmartPlugCreate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE),
 ):
     """Create a new smart plug."""
     # Validate printer_id if provided
@@ -57,21 +64,17 @@ async def create_smart_plug(
             raise HTTPException(400, "Printer not found")
 
         # Check if printer already has a plug assigned
-        # Scripts can coexist with other plugs (they're for multi-device control, not power on/off)
-        is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.")
-        if not is_script:
-            # For non-script plugs, check there's no other non-script plug assigned
-            result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id))
-            existing = result.scalar_one_or_none()
-            if existing:
-                # Allow if existing plug is a script
-                existing_is_script = (
-                    existing.plug_type == "homeassistant"
-                    and existing.ha_entity_id
-                    and existing.ha_entity_id.startswith("script.")
+        # Tasmota plugs: only one per printer (physical power device)
+        # HA entities: allow multiple per printer (for different automations)
+        if data.plug_type == "tasmota":
+            result = await db.execute(
+                select(SmartPlug).where(
+                    SmartPlug.printer_id == data.printer_id,
+                    SmartPlug.plug_type == "tasmota",
                 )
-                if not existing_is_script:
-                    raise HTTPException(400, "This printer already has a smart plug assigned")
+            )
+            if result.scalar_one_or_none():
+                raise HTTPException(400, "This printer already has a Tasmota plug assigned")
 
     # For MQTT plugs, ensure MQTT broker is configured and service is connected
     if data.plug_type == "mqtt":
@@ -103,7 +106,15 @@ async def create_smart_plug(
                     f"Failed to connect to MQTT broker at {mqtt_broker}. Please check your MQTT settings.",
                 )
 
-    plug = SmartPlug(**data.model_dump())
+    plug_data = data.model_dump()
+
+    # For HA entities, default auto_on and auto_off to False
+    # (they're for automations, not power control like Tasmota plugs)
+    if data.plug_type == "homeassistant":
+        plug_data["auto_on"] = False
+        plug_data["auto_off"] = False
+
+    plug = SmartPlug(**plug_data)
     db.add(plug)
     await db.commit()
     await db.refresh(plug)
@@ -142,7 +153,11 @@ async def create_smart_plug(
 
 
 @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
-async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
+async def get_smart_plug_by_printer(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get the main smart plug assigned to a printer.
 
     When multiple plugs are assigned (e.g., a regular plug + script),
@@ -165,26 +180,25 @@ async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(
 
 
 @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
-async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
-    """Get all HA script plugs assigned to a printer.
+async def get_script_plugs_by_printer(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
+    """Get all HA entities assigned to a printer for display on printer card.
 
-    Returns only script entities (script.*) for the printer that have
+    Returns HA entities (switches, scripts, lights, etc.) for the printer that have
     show_on_printer_card enabled.
-    Used to display "Run Script" buttons alongside the main power plug.
+    Used to display action buttons alongside the main power plug.
     """
     result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
     plugs = result.scalars().all()
 
-    # Filter to only scripts with show_on_printer_card enabled
-    scripts = [
-        plug
-        for plug in plugs
-        if plug.plug_type == "homeassistant"
-        and plug.ha_entity_id
-        and plug.ha_entity_id.startswith("script.")
-        and plug.show_on_printer_card
+    # Filter to HA entities with show_on_printer_card enabled
+    ha_entities = [
+        plug for plug in plugs if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.show_on_printer_card
     ]
-    return scripts
+    return ha_entities
 
 
 # Tasmota Discovery Endpoints
@@ -244,7 +258,10 @@ class DiscoveredTasmotaDevice(BaseModel):
 
 
 @router.post("/discover/scan", response_model=TasmotaScanStatus)
-async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=None)):
+async def start_tasmota_scan(
+    request: TasmotaScanRequest | None = Body(default=None),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Start an IP range scan for Tasmota devices.
 
     Auto-detects local network if no IP range provided.
@@ -268,7 +285,9 @@ async def start_tasmota_scan(request: TasmotaScanRequest | None = Body(default=N
 
 
 @router.get("/discover/status", response_model=TasmotaScanStatus)
-async def get_tasmota_scan_status():
+async def get_tasmota_scan_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get the current Tasmota scan status."""
     scanned, total = tasmota_scanner.progress
     return TasmotaScanStatus(
@@ -279,7 +298,9 @@ async def get_tasmota_scan_status():
 
 
 @router.post("/discover/stop", response_model=TasmotaScanStatus)
-async def stop_tasmota_scan():
+async def stop_tasmota_scan(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Stop the current Tasmota scan."""
     tasmota_scanner.stop()
     scanned, total = tasmota_scanner.progress
@@ -291,7 +312,9 @@ async def stop_tasmota_scan():
 
 
 @router.get("/discover/devices", response_model=list[DiscoveredTasmotaDevice])
-async def get_discovered_tasmota_devices():
+async def get_discovered_tasmota_devices(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get list of discovered Tasmota devices."""
     return [
         DiscoveredTasmotaDevice(
@@ -309,7 +332,10 @@ async def get_discovered_tasmota_devices():
 
 
 @router.post("/ha/test-connection", response_model=HATestConnectionResponse)
-async def test_ha_connection(request: HATestConnectionRequest):
+async def test_ha_connection(
+    request: HATestConnectionRequest,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+):
     """Test connection to Home Assistant."""
     result = await homeassistant_service.test_connection(request.url, request.token)
     return HATestConnectionResponse(**result)
@@ -319,6 +345,7 @@ async def test_ha_connection(request: HATestConnectionRequest):
 async def list_ha_entities(
     db: AsyncSession = Depends(get_db),
     search: str | None = None,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
 ):
     """List available Home Assistant entities.
 
@@ -340,7 +367,10 @@ async def list_ha_entities(
 
 
 @router.get("/ha/sensors", response_model=list[HASensorEntity])
-async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
+async def list_ha_sensor_entities(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """List available Home Assistant sensor entities for energy monitoring.
 
     Returns sensors with power/energy units (W, kW, kWh, Wh).
@@ -359,7 +389,11 @@ async def list_ha_sensor_entities(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/{plug_id}", response_model=SmartPlugResponse)
-async def get_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
+async def get_smart_plug(
+    plug_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get a specific smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
@@ -373,6 +407,7 @@ async def update_smart_plug(
     plug_id: int,
     data: SmartPlugUpdate,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE),
 ):
     """Update a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
@@ -391,30 +426,20 @@ async def update_smart_plug(
         if not result.scalar_one_or_none():
             raise HTTPException(400, "Printer not found")
 
-        # Check if that printer already has a different plug assigned
-        # Scripts can coexist with other plugs
-        # Determine if the plug being updated is/will be a script
-        new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id)
+        # Check if that printer already has a different Tasmota plug assigned
+        # Tasmota plugs: only one per printer (physical power device)
+        # HA entities: allow multiple per printer (for different automations)
         new_plug_type = update_data.get("plug_type", plug.plug_type)
-        is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.")
-
-        if not is_script:
+        if new_plug_type == "tasmota":
             result = await db.execute(
                 select(SmartPlug).where(
                     SmartPlug.printer_id == new_printer_id,
                     SmartPlug.id != plug_id,
+                    SmartPlug.plug_type == "tasmota",
                 )
             )
-            existing = result.scalar_one_or_none()
-            if existing:
-                # Allow if existing plug is a script
-                existing_is_script = (
-                    existing.plug_type == "homeassistant"
-                    and existing.ha_entity_id
-                    and existing.ha_entity_id.startswith("script.")
-                )
-                if not existing_is_script:
-                    raise HTTPException(400, "This printer already has a smart plug assigned")
+            if result.scalar_one_or_none():
+                raise HTTPException(400, "This printer already has a Tasmota plug assigned")
 
     # Track old MQTT settings for comparison
     old_plug_type = plug.plug_type
@@ -489,7 +514,11 @@ async def update_smart_plug(
 
 
 @router.delete("/{plug_id}")
-async def delete_smart_plug(plug_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_smart_plug(
+    plug_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE),
+):
     """Delete a smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
@@ -529,6 +558,7 @@ async def control_smart_plug(
     plug_id: int,
     control: SmartPlugControl,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
 ):
     """Manual control: on/off/toggle."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
@@ -635,7 +665,11 @@ async def trigger_associated_scripts(printer_id: int, plug_state: str, db: Async
 
 
 @router.get("/{plug_id}/status", response_model=SmartPlugStatus)
-async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)):
+async def get_plug_status(
+    plug_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+):
     """Get current plug status from device including energy data."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
@@ -763,7 +797,10 @@ async def check_power_alerts(plug: SmartPlug, current_power: float | None, db: A
 
 
 @router.post("/test-connection")
-async def test_connection(data: SmartPlugTestConnection):
+async def test_connection(
+    data: SmartPlugTestConnection,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CONTROL),
+):
     """Test connection to a Tasmota device."""
     result = await tasmota_service.test_connection(
         data.ip_address,

+ 71 - 8
backend/app/api/routes/spoolman.py

@@ -7,9 +7,12 @@ from pydantic import BaseModel
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
+from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.spoolman import (
     close_spoolman_client,
@@ -72,7 +75,10 @@ async def get_spoolman_settings(db: AsyncSession) -> tuple[bool, str, str]:
 
 
 @router.get("/status", response_model=SpoolmanStatus)
-async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
+async def get_spoolman_status(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get Spoolman integration status."""
     enabled, url, _ = await get_spoolman_settings(db)
 
@@ -89,7 +95,10 @@ async def get_spoolman_status(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/connect")
-async def connect_spoolman(db: AsyncSession = Depends(get_db)):
+async def connect_spoolman(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Connect to Spoolman server using configured URL."""
     enabled, url, _ = await get_spoolman_settings(db)
 
@@ -119,7 +128,9 @@ async def connect_spoolman(db: AsyncSession = Depends(get_db)):
 
 
 @router.post("/disconnect")
-async def disconnect_spoolman():
+async def disconnect_spoolman(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Disconnect from Spoolman server."""
     await close_spoolman_client()
     return {"success": True, "message": "Disconnected from Spoolman"}
@@ -129,6 +140,7 @@ async def disconnect_spoolman():
 async def sync_printer_ams(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
     """Sync AMS data from a specific printer to Spoolman."""
     # Check if Spoolman is enabled and connected
@@ -267,7 +279,10 @@ async def sync_printer_ams(
 
 
 @router.post("/sync-all", response_model=SyncResult)
-async def sync_all_printers(db: AsyncSession = Depends(get_db)):
+async def sync_all_printers(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+):
     """Sync AMS data from all connected printers to Spoolman."""
     # Check if Spoolman is enabled
     enabled, url, _ = await get_spoolman_settings(db)
@@ -392,7 +407,10 @@ async def sync_all_printers(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/spools")
-async def get_spools(db: AsyncSession = Depends(get_db)):
+async def get_spools(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get all spools from Spoolman."""
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
@@ -413,7 +431,10 @@ async def get_spools(db: AsyncSession = Depends(get_db)):
 
 
 @router.get("/filaments")
-async def get_filaments(db: AsyncSession = Depends(get_db)):
+async def get_filaments(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get all filaments from Spoolman."""
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
@@ -445,7 +466,10 @@ class UnlinkedSpool(BaseModel):
 
 
 @router.get("/spools/unlinked", response_model=list[UnlinkedSpool])
-async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
+async def get_unlinked_spools(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
     """Get all Spoolman spools that don't have a tag (not linked to AMS)."""
     enabled, url, _ = await get_spoolman_settings(db)
     if not enabled:
@@ -468,7 +492,9 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
         # Check if spool has a tag in extra field
         extra = spool.get("extra", {}) or {}
         tag = extra.get("tag", "")
-        if not tag:
+        # Remove quotes if present (JSON encoded string) and check if empty
+        clean_tag = tag.strip('"') if tag else ""
+        if not clean_tag:
             filament = spool.get("filament", {}) or {}
             unlinked.append(
                 UnlinkedSpool(
@@ -484,6 +510,42 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
     return unlinked
 
 
+@router.get("/spools/linked")
+async def get_linked_spools(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_READ),
+):
+    """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
+    enabled, url, _ = await get_spoolman_settings(db)
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    spools = await client.get_spools()
+    linked: dict[str, int] = {}
+
+    for spool in spools:
+        # Check if spool has a tag in extra field
+        extra = spool.get("extra", {}) or {}
+        tag = extra.get("tag", "")
+        if tag:
+            # Remove quotes if present (JSON encoded string)
+            clean_tag = tag.strip('"').upper()
+            if clean_tag:
+                linked[clean_tag] = spool["id"]
+
+    return {"linked": linked}
+
+
 class LinkSpoolRequest(BaseModel):
     """Request to link a Spoolman spool to an AMS tray."""
 
@@ -495,6 +557,7 @@ async def link_spool(
     spool_id: int,
     request: LinkSpoolRequest,
     db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
 ):
     """Link a Spoolman spool to an AMS tray by setting the tag to tray_uuid."""
     enabled, url, _ = await get_spoolman_settings(db)

+ 19 - 6
backend/app/api/routes/support.py

@@ -15,14 +15,17 @@ from pydantic import BaseModel
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import async_session
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 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
 
 router = APIRouter(prefix="/support", tags=["support"])
 logger = logging.getLogger(__name__)
@@ -107,7 +110,9 @@ def _apply_log_level(debug: bool):
 
 
 @router.get("/debug-logging", response_model=DebugLoggingState)
-async def get_debug_logging_state():
+async def get_debug_logging_state(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Get current debug logging state."""
     global _debug_logging_enabled, _debug_logging_enabled_at
 
@@ -128,7 +133,10 @@ async def get_debug_logging_state():
 
 
 @router.post("/debug-logging", response_model=DebugLoggingState)
-async def toggle_debug_logging(toggle: DebugLoggingToggle):
+async def toggle_debug_logging(
+    toggle: DebugLoggingToggle,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Enable or disable debug logging."""
     global _debug_logging_enabled, _debug_logging_enabled_at
 
@@ -273,6 +281,7 @@ async def get_logs(
     limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
     level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
     search: str | None = Query(None, description="Search in message or logger name"),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
 ):
     """Get recent application log entries with optional filtering."""
     entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
@@ -285,7 +294,9 @@ async def get_logs(
 
 
 @router.delete("/logs")
-async def clear_logs():
+async def clear_logs(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Clear the application log file."""
     log_file = settings.log_dir / "bambuddy.log"
 
@@ -297,8 +308,8 @@ async def clear_logs():
             logger.info("Log file cleared by user")
             return {"message": "Logs cleared successfully"}
         except Exception as e:
-            logger.error(f"Error clearing log file: {e}")
-            raise HTTPException(status_code=500, detail=f"Failed to clear logs: {e}")
+            logger.error(f"Error clearing log file: {e}", exc_info=True)
+            raise HTTPException(status_code=500, detail="Failed to clear logs. Check server logs for details.")
 
     return {"message": "Log file does not exist"}
 
@@ -445,7 +456,9 @@ def _get_log_content(max_bytes: int = 10 * 1024 * 1024) -> bytes:
 
 
 @router.get("/bundle")
-async def generate_support_bundle():
+async def generate_support_bundle(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
     """Generate a support bundle ZIP file for issue reporting."""
     global _debug_logging_enabled, _debug_logging_enabled_at
 

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

@@ -9,13 +9,16 @@ from fastapi import APIRouter, Depends
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 from backend.app.services.printer_manager import printer_manager
 
 router = APIRouter(prefix="/system", tags=["system"])
@@ -60,7 +63,10 @@ def format_uptime(seconds: float) -> str:
 
 
 @router.get("/info")
-async def get_system_info(db: AsyncSession = Depends(get_db)):
+async def get_system_info(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
     """Get comprehensive system information."""
 
     # Database stats

+ 18 - 4
backend/app/api/routes/updates.py

@@ -11,8 +11,11 @@ import httpx
 from fastapi import APIRouter, BackgroundTasks, Depends
 from sqlalchemy.ext.asyncio import AsyncSession
 
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, GITHUB_REPO, settings
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
 
 logger = logging.getLogger(__name__)
 
@@ -153,7 +156,10 @@ def is_newer_version(latest: str, current: str) -> bool:
 
 @router.get("/version")
 async def get_version():
-    """Get current application version."""
+    """Get current application version.
+
+    Note: Unauthenticated - needed to display version in UI without login.
+    """
     return {
         "version": APP_VERSION,
         "repo": GITHUB_REPO,
@@ -161,7 +167,10 @@ async def get_version():
 
 
 @router.get("/check")
-async def check_for_updates(db: AsyncSession = Depends(get_db)):
+async def check_for_updates(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
     """Check GitHub for available updates."""
     global _update_status
 
@@ -432,7 +441,10 @@ async def _perform_update():
 
 
 @router.post("/apply")
-async def apply_update(background_tasks: BackgroundTasks):
+async def apply_update(
+    background_tasks: BackgroundTasks,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
     """Apply available update (git pull + rebuild)."""
     global _update_status
 
@@ -473,6 +485,8 @@ async def apply_update(background_tasks: BackgroundTasks):
 
 
 @router.get("/status")
-async def get_update_status():
+async def get_update_status(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ),
+):
     """Get current update status."""
     return _update_status

+ 67 - 1
backend/app/core/auth.py

@@ -1,7 +1,10 @@
 from __future__ import annotations
 
+import logging
+import os
 import secrets
 from datetime import datetime, timedelta
+from pathlib import Path
 from typing import Annotated
 
 import jwt
@@ -19,13 +22,76 @@ from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 
+logger = logging.getLogger(__name__)
+
 # 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 _get_jwt_secret() -> str:
+    """Get the JWT secret key from environment, file, or generate a new one.
+
+    Priority:
+    1. JWT_SECRET_KEY environment variable
+    2. .jwt_secret file in data directory
+    3. Generate new random secret and save to file
+
+    Returns:
+        The JWT secret key
+    """
+    # 1. Check environment variable first
+    env_secret = os.environ.get("JWT_SECRET_KEY")
+    if env_secret:
+        logger.info("Using JWT secret from JWT_SECRET_KEY environment variable")
+        return env_secret
+
+    # 2. Check for secret file in data directory
+    # Use DATA_DIR env var (same as rest of app), fallback to data/ subdirectory
+    data_dir_env = os.environ.get("DATA_DIR")
+    if data_dir_env:
+        data_dir = Path(data_dir_env)
+    else:
+        # Fallback to data/ subdirectory under project root (not project root itself!)
+        data_dir = Path(__file__).parent.parent.parent.parent / "data"
+    secret_file = data_dir / ".jwt_secret"
+
+    if secret_file.exists():
+        try:
+            secret = secret_file.read_text().strip()
+            if secret and len(secret) >= 32:
+                logger.info("Using JWT secret from %s", secret_file)
+                return secret
+        except Exception as e:
+            logger.warning("Failed to read JWT secret file: %s", e)
+
+    # 3. Generate new random secret
+    new_secret = secrets.token_urlsafe(64)
+
+    # Try to save it
+    try:
+        data_dir.mkdir(parents=True, exist_ok=True)
+        # Note: CodeQL flags this as "clear-text storage of sensitive information" but this is
+        # intentional and secure - JWT secrets must be readable by the app, we set 0600 permissions,
+        # and this is standard practice for self-hosted applications (same as .env files).
+        secret_file.write_text(new_secret)  # nosec B105 - intentional secure storage
+        # Restrict permissions (owner read/write only)
+        secret_file.chmod(0o600)
+        logger.info("Generated new JWT secret and saved to %s", secret_file)
+    except Exception as e:
+        logger.warning(
+            "Could not save JWT secret to file (%s). "
+            "Secret will be regenerated on restart, invalidating existing tokens. "
+            "Set JWT_SECRET_KEY environment variable for persistence.",
+            e,
+        )
+
+    return new_secret
+
+
 # JWT settings
-SECRET_KEY = "bambuddy-secret-key-change-in-production"  # TODO: Move to settings/env
+SECRET_KEY = _get_jwt_secret()
 ALGORITHM = "HS256"
 ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 7 days
 

+ 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.7b"
+APP_VERSION = "0.1.8b"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # App directory - where the application is installed (for static files)

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

@@ -1044,6 +1044,12 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add target_location column to print_queue for location-based filtering (Issue #220)
+    try:
+        await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)"))
+    except Exception:
+        pass
+
     # Migration: Convert absolute paths to relative paths in library_files table
     # This ensures backup/restore portability across different installations
     try:

+ 165 - 2
backend/app/main.py

@@ -2500,16 +2500,37 @@ async def lifespan(app: FastAPI):
             vp_access_code = await get_setting(db, "virtual_printer_access_code") or ""
             vp_mode = await get_setting(db, "virtual_printer_mode") or "immediate"
             vp_model = await get_setting(db, "virtual_printer_model") or ""
+            vp_target_printer_id = await get_setting(db, "virtual_printer_target_printer_id")
 
-            if vp_access_code:
+            # Look up printer IP and serial if in proxy mode
+            vp_target_ip = ""
+            vp_target_serial = ""
+            if vp_mode == "proxy" and vp_target_printer_id:
+                from backend.app.models.printer import Printer
+
+                result = await db.execute(select(Printer).where(Printer.id == int(vp_target_printer_id)))
+                printer = result.scalar_one_or_none()
+                if printer:
+                    vp_target_ip = printer.ip_address
+                    vp_target_serial = printer.serial_number
+
+            # Proxy mode requires target IP, other modes require access code
+            can_start = (vp_mode == "proxy" and vp_target_ip) or (vp_mode != "proxy" and vp_access_code)
+
+            if can_start:
                 try:
                     await virtual_printer_manager.configure(
                         enabled=True,
                         access_code=vp_access_code,
                         mode=vp_mode,
                         model=vp_model,
+                        target_printer_ip=vp_target_ip,
+                        target_printer_serial=vp_target_serial,
                     )
-                    logging.info(f"Virtual printer started (model={vp_model or 'default'})")
+                    if vp_mode == "proxy":
+                        logging.info(f"Virtual printer proxy started (target={vp_target_ip})")
+                    else:
+                        logging.info(f"Virtual printer started (model={vp_model or 'default'})")
                 except Exception as e:
                     logging.warning(f"Failed to start virtual printer: {e}")
 
@@ -2537,6 +2558,148 @@ app = FastAPI(
     lifespan=lifespan,
 )
 
+
+# =============================================================================
+# Authentication Middleware - Secures ALL API routes by default
+# =============================================================================
+# Public routes that don't require authentication even when auth is enabled
+PUBLIC_API_ROUTES = {
+    # Auth routes needed before/during login
+    "/api/v1/auth/status",
+    "/api/v1/auth/login",
+    "/api/v1/auth/setup",  # Needed for initial setup and recovery
+    # Version check for updates (no sensitive data)
+    "/api/v1/updates/version",
+    # Metrics endpoint handles its own prometheus_token authentication
+    "/api/v1/metrics",
+}
+
+# Route prefixes that are public (for routes with dynamic segments)
+PUBLIC_API_PREFIXES = [
+    # WebSocket connections handle their own auth
+    "/api/v1/ws",
+]
+
+# Route patterns that are public (read-only display data)
+# These are checked with "in path" - needed because browsers load images/videos
+# via <img src> and <video src> which don't include Authorization headers
+PUBLIC_API_PATTERNS = [
+    # Thumbnails
+    "/thumbnail",  # /archives/{id}/thumbnail, /library/files/{id}/thumbnail
+    "/plate-thumbnail/",  # /archives/{id}/plate-thumbnail/{plate_id}
+    # Images and media
+    "/photos/",  # /archives/{id}/photos/{filename}
+    "/project-image/",  # /archives/{id}/project-image/{path}
+    "/qrcode",  # /archives/{id}/qrcode
+    "/timelapse",  # /archives/{id}/timelapse (video)
+    "/cover",  # /printers/{id}/cover
+    "/icon",  # /external-links/{id}/icon
+    # Camera (streams loaded via <img> tag)
+    "/camera/stream",  # /printers/{id}/camera/stream
+    "/camera/snapshot",  # /printers/{id}/camera/snapshot
+]
+
+
+@app.middleware("http")
+async def auth_middleware(request, call_next):
+    """Enforce authentication on all API routes when auth is enabled.
+
+    This middleware provides defense-in-depth by checking auth at the API gateway level,
+    regardless of whether individual routes have auth dependencies.
+    """
+    from starlette.responses import JSONResponse
+
+    path = request.url.path
+
+    # Only apply to API routes
+    if not path.startswith("/api/"):
+        return await call_next(request)
+
+    # Allow public routes
+    if path in PUBLIC_API_ROUTES:
+        return await call_next(request)
+
+    # Allow public prefixes
+    for prefix in PUBLIC_API_PREFIXES:
+        if path.startswith(prefix):
+            return await call_next(request)
+
+    # Allow public patterns (read-only display data like thumbnails)
+    for pattern in PUBLIC_API_PATTERNS:
+        if pattern in path:
+            return await call_next(request)
+
+    # Check if auth is enabled
+    try:
+        async with async_session() as db:
+            from backend.app.core.auth import is_auth_enabled
+
+            auth_enabled = await is_auth_enabled(db)
+
+        if not auth_enabled:
+            # Auth disabled, allow all requests
+            return await call_next(request)
+    except Exception:
+        # If we can't check auth status, allow request (fail open for DB issues)
+        return await call_next(request)
+
+    # Auth is enabled - require valid token
+    auth_header = request.headers.get("Authorization")
+    x_api_key = request.headers.get("X-API-Key")
+
+    # Check for API key auth first
+    if x_api_key or (auth_header and auth_header.startswith("Bearer bb_")):
+        # API key authentication - let the request through to be validated by route handler
+        # API keys are validated per-route since they have different permission levels
+        return await call_next(request)
+
+    # Check for JWT auth
+    if not auth_header or not auth_header.startswith("Bearer "):
+        return JSONResponse(
+            status_code=401,
+            content={"detail": "Authentication required"},
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
+    # Validate JWT token
+    try:
+        import jwt
+
+        from backend.app.core.auth import ALGORITHM, SECRET_KEY
+
+        token = auth_header.replace("Bearer ", "")
+        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+        username = payload.get("sub")
+        if not username:
+            raise ValueError("No username in token")
+
+        # Verify user exists and is active
+        async with async_session() as db:
+            from backend.app.core.auth import get_user_by_username
+
+            user = await get_user_by_username(db, username)
+            if not user or not user.is_active:
+                return JSONResponse(
+                    status_code=401,
+                    content={"detail": "User not found or inactive"},
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+    except jwt.ExpiredSignatureError:
+        return JSONResponse(
+            status_code=401,
+            content={"detail": "Token has expired"},
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    except (jwt.InvalidTokenError, ValueError, Exception):
+        return JSONResponse(
+            status_code=401,
+            content={"detail": "Invalid token"},
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
+    return await call_next(request)
+
+
 # API routes
 app.include_router(auth.router, prefix=app_settings.api_prefix)
 app.include_router(users.router, prefix=app_settings.api_prefix)

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

@@ -18,6 +18,9 @@ class PrintQueueItem(Base):
     # Target printer model for model-based assignment (mutually exclusive with printer_id)
     # When set, scheduler assigns to any idle printer of matching model
     target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+    # Target location filter for model-based assignment (only used with target_model)
+    # When set, only printers in this location are considered
+    target_location: Mapped[str | None] = mapped_column(String(100), nullable=True)
     # Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
     # Used by scheduler to validate printer has compatible filaments loaded
     required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 5 - 2
backend/app/schemas/cloud.py

@@ -10,10 +10,11 @@ class CloudLoginRequest(BaseModel):
 
 
 class CloudVerifyRequest(BaseModel):
-    """Request to verify login with 2FA code."""
+    """Request to verify login with 2FA code (email or TOTP)."""
 
     email: str = Field(..., description="Bambu Lab account email")
-    code: str = Field(..., description="6-digit verification code from email")
+    code: str = Field(..., description="6-digit verification code")
+    tfa_key: str | None = Field(None, description="TFA key for TOTP verification (from login response)")
 
 
 class CloudLoginResponse(BaseModel):
@@ -22,6 +23,8 @@ class CloudLoginResponse(BaseModel):
     success: bool
     needs_verification: bool = False
     message: str
+    verification_type: str | None = None  # "email" or "totp"
+    tfa_key: str | None = None  # Key needed for TOTP verification
 
 
 class CloudAuthStatus(BaseModel):

+ 3 - 0
backend/app/schemas/print_queue.py

@@ -18,6 +18,7 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
+    target_location: str | None = None  # Target location filter (only used with target_model)
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     # Either archive_id OR library_file_id must be provided
     archive_id: int | None = None
@@ -43,6 +44,7 @@ class PrintQueueItemCreate(BaseModel):
 class PrintQueueItemUpdate(BaseModel):
     printer_id: int | None = None
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
+    target_location: str | None = None  # Target location filter (only used with target_model)
     position: int | None = None
     scheduled_time: datetime | None = None
     require_previous_success: bool | None = None
@@ -63,6 +65,7 @@ class PrintQueueItemResponse(BaseModel):
     id: int
     printer_id: int | None  # None = unassigned
     target_model: str | None = None  # Target printer model for model-based assignment
+    target_location: str | None = None  # Target location filter for model-based assignment
     required_filament_types: list[str] | None = None  # Required filament types for model-based assignment
     waiting_reason: str | None = None  # Why a model-based job hasn't started yet
     archive_id: int | None  # None if library_file_id is set (archive created at print start)

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

@@ -933,11 +933,13 @@ class ArchiveService:
         return archive
 
     async def get_archive(self, archive_id: int) -> PrintArchive | None:
-        """Get an archive by ID with creator loaded."""
+        """Get an archive by ID with relationships loaded."""
         from sqlalchemy.orm import selectinload
 
         result = await self.db.execute(
-            select(PrintArchive).options(selectinload(PrintArchive.created_by)).where(PrintArchive.id == archive_id)
+            select(PrintArchive)
+            .options(selectinload(PrintArchive.created_by), selectinload(PrintArchive.project))
+            .where(PrintArchive.id == archive_id)
         )
         return result.scalar_one_or_none()
 

+ 110 - 8
backend/app/services/bambu_cloud.py

@@ -56,9 +56,9 @@ class BambuCloudService:
 
     async def login_request(self, email: str, password: str) -> dict:
         """
-        Initiate login - this will trigger a verification code email.
+        Initiate login - this will trigger either email verification or TOTP prompt.
 
-        Returns dict with login status and whether verification is needed.
+        Returns dict with login status, verification type, and tfaKey if needed.
         """
         try:
             response = await self._client.post(
@@ -71,12 +71,33 @@ class BambuCloudService:
             )
 
             data = response.json()
+            logger.debug(
+                f"Login response: status={response.status_code}, loginType={data.get('loginType')}, hasTfaKey={'tfaKey' in data}"
+            )
 
             if response.status_code == 200:
-                # Check if we need verification code
-                # Bambu API returns loginType or may require tfaKey
-                if data.get("loginType") == "verifyCode" or "tfaKey" in data:
-                    return {"success": False, "needs_verification": True, "message": "Verification code sent to email"}
+                login_type = data.get("loginType")
+                tfa_key = data.get("tfaKey")
+
+                # TOTP authentication required
+                if login_type == "tfa" or (tfa_key and login_type != "verifyCode"):
+                    return {
+                        "success": False,
+                        "needs_verification": True,
+                        "verification_type": "totp",
+                        "tfa_key": tfa_key,
+                        "message": "Enter the code from your authenticator app",
+                    }
+
+                # Email verification required
+                if login_type == "verifyCode":
+                    return {
+                        "success": False,
+                        "needs_verification": True,
+                        "verification_type": "email",
+                        "tfa_key": None,
+                        "message": "Verification code sent to email",
+                    }
 
                 # Direct login success (rare, usually needs 2FA)
                 if "accessToken" in data:
@@ -93,7 +114,7 @@ class BambuCloudService:
 
     async def verify_code(self, email: str, code: str) -> dict:
         """
-        Complete login with verification code.
+        Complete login with email verification code.
         """
         try:
             response = await self._client.post(
@@ -106,6 +127,7 @@ class BambuCloudService:
             )
 
             data = response.json()
+            logger.debug(f"Email verify response: status={response.status_code}, hasToken={'accessToken' in data}")
 
             if response.status_code == 200 and "accessToken" in data:
                 self._set_tokens(data)
@@ -114,9 +136,89 @@ class BambuCloudService:
             return {"success": False, "message": data.get("message", "Verification failed")}
 
         except Exception as e:
-            logger.error(f"Verification failed: {e}")
+            logger.error(f"Email verification failed: {e}")
             raise BambuCloudAuthError(f"Verification failed: {e}")
 
+    async def verify_totp(self, tfa_key: str, code: str) -> dict:
+        """
+        Complete login with TOTP code from authenticator app.
+
+        Args:
+            tfa_key: The tfaKey returned from initial login request
+            code: 6-digit TOTP code from authenticator app
+        """
+        try:
+            # TFA endpoint is on bambulab.com, NOT api.bambulab.com
+            # Requires browser-like headers to bypass Cloudflare
+            tfa_url = "https://bambulab.com/api/sign-in/tfa"
+            if "bambulab.cn" in self.base_url:
+                tfa_url = "https://bambulab.cn/api/sign-in/tfa"
+
+            browser_headers = {
+                "Content-Type": "application/json",
+                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+                "Accept": "application/json, text/plain, */*",
+                "Accept-Language": "en-US,en;q=0.9",
+                "Origin": "https://bambulab.com",
+                "Referer": "https://bambulab.com/",
+            }
+
+            response = await self._client.post(
+                tfa_url,
+                headers=browser_headers,
+                json={
+                    "tfaKey": tfa_key,
+                    "tfaCode": code,
+                },
+            )
+
+            logger.debug(
+                f"TOTP verify response: status={response.status_code}, body={response.text[:200] if response.text else '(empty)'}"
+            )
+
+            # Handle empty response
+            if not response.text or not response.text.strip():
+                logger.warning(f"TOTP verification returned empty response (status {response.status_code})")
+                return {"success": False, "message": "Bambu Cloud returned empty response. Please try again."}
+
+            try:
+                data = response.json()
+            except Exception as json_err:
+                logger.error(f"Failed to parse TOTP response: {json_err}, body: {response.text[:500]}")
+                return {"success": False, "message": "Invalid response from Bambu Cloud"}
+
+            # Token might be in accessToken, token field, or cookies
+            access_token = data.get("accessToken") or data.get("token")
+
+            # Also check cookies for token
+            if not access_token:
+                for cookie in response.cookies:
+                    if "token" in cookie.lower():
+                        access_token = response.cookies.get(cookie)
+                        break
+
+            if response.status_code == 200 and access_token:
+                self.access_token = access_token
+                self.refresh_token = data.get("refreshToken")
+                from datetime import datetime, timedelta
+
+                self.token_expiry = datetime.now() + timedelta(days=30)
+                return {"success": True, "message": "Login successful"}
+
+            # Provide helpful error message
+            error_msg = data.get("message", "")
+            if "expired" in error_msg.lower():
+                return {"success": False, "message": "TOTP session expired. Please try logging in again."}
+            if not error_msg:
+                error_msg = f"TOTP verification failed (status {response.status_code})"
+
+            return {"success": False, "message": error_msg}
+
+        except Exception as e:
+            logger.error(f"TOTP verification failed: {e}")
+            # Return error instead of raising - don't trigger 401/500
+            return {"success": False, "message": f"TOTP verification error: {e}"}
+
     def _set_tokens(self, data: dict):
         """Set tokens from login response."""
         self.access_token = data.get("accessToken")

+ 2 - 1
backend/app/services/bambu_ftp.py

@@ -78,7 +78,8 @@ class BambuFTPClient:
     FTP_PORT = 990
     DEFAULT_TIMEOUT = 30  # Default timeout in seconds (increased for A1 printers)
     # Models that need SSL session reuse disabled (A1 series has FTP issues with session reuse)
-    SKIP_SESSION_REUSE_MODELS = ("A1", "A1 Mini", "P1S", "P1P", "P2S")
+    # P2S should use normal session reuse like X1C/P1S, not skip it
+    SKIP_SESSION_REUSE_MODELS = ("A1", "A1 Mini", "P1S", "P1P")
 
     def __init__(
         self,

+ 18 - 1
backend/app/services/bambu_mqtt.py

@@ -249,6 +249,7 @@ class BambuMQTTClient:
         ip_address: str,
         serial_number: str,
         access_code: str,
+        model: str | None = None,
         on_state_change: Callable[[PrinterState], None] | None = None,
         on_print_start: Callable[[dict], None] | None = None,
         on_print_complete: Callable[[dict], None] | None = None,
@@ -258,6 +259,7 @@ class BambuMQTTClient:
         self.ip_address = ip_address
         self.serial_number = serial_number
         self.access_code = access_code
+        self.model = model
         self.on_state_change = on_state_change
         self.on_print_start = on_print_start
         self.on_print_complete = on_print_complete
@@ -2075,6 +2077,12 @@ class BambuMQTTClient:
                 }
             }
 
+            # P2S-specific parameter adjustments
+            # P2S printer doesn't support vibration calibration like X1/P1 series
+            if self.model and self.model.upper().strip() in ("P2S", "N7"):
+                command["print"]["vibration_cali"] = False
+                logger.info(f"[{self.serial_number}] P2S detected: disabling vibration_cali")
+
             # Add AMS mapping if provided
             if ams_mapping is not None:
                 command["print"]["ams_mapping"] = ams_mapping
@@ -2083,7 +2091,16 @@ class BambuMQTTClient:
             logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}")
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             return True
-        return False
+        else:
+            # Log why we couldn't send the command
+            if not self._client:
+                logger.error(f"[{self.serial_number}] Cannot start print: MQTT client not initialized")
+            elif not self.state.connected:
+                logger.error(
+                    f"[{self.serial_number}] Cannot start print: Printer not connected (client exists but disconnected). "
+                    f"Connection state: {self.state.connected}, Last message: {self._last_message_time}"
+                )
+            return False
 
     def stop_print(self) -> bool:
         """Stop the current print job."""

+ 21 - 7
backend/app/services/print_scheduler.py

@@ -148,7 +148,7 @@ class PrintScheduler:
                             pass
 
                     printer_id, waiting_reason = await self._find_idle_printer_for_model(
-                        db, item.target_model, busy_printers, required_types
+                        db, item.target_model, busy_printers, required_types, item.target_location
                     )
 
                     # Update waiting_reason if changed and send notification when first waiting
@@ -225,6 +225,7 @@ class PrintScheduler:
         model: str,
         exclude_ids: set[int],
         required_filament_types: list[str] | None = None,
+        target_location: str | None = None,
     ) -> tuple[int | None, str | None]:
         """Find an idle, connected printer matching the model with compatible filaments.
 
@@ -234,6 +235,7 @@ class PrintScheduler:
             exclude_ids: Printer IDs to exclude (already busy)
             required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
                                      If provided, only printers with all required types loaded will match.
+            target_location: Optional location filter. If provided, only printers in this location are considered.
 
         Returns:
             Tuple of (printer_id, waiting_reason):
@@ -242,15 +244,22 @@ class PrintScheduler:
         """
         # Normalize model name and use case-insensitive matching
         normalized_model = normalize_printer_model(model) or model
-        result = await db.execute(
+        query = (
             select(Printer)
             .where(func.lower(Printer.model) == normalized_model.lower())
             .where(Printer.is_active == True)  # noqa: E712
         )
+
+        # Add location filter if specified
+        if target_location:
+            query = query.where(Printer.location == target_location)
+
+        result = await db.execute(query)
         printers = list(result.scalars().all())
 
+        location_suffix = f" in {target_location}" if target_location else ""
         if not printers:
-            return None, f"No active {normalized_model} printers configured"
+            return None, f"No active {normalized_model} printers{location_suffix} configured"
 
         # Track reasons for skipping printers
         printers_busy = []
@@ -295,7 +304,7 @@ class PrintScheduler:
         if printers_offline:
             reasons.append(f"Offline: {', '.join(printers_offline)}")
 
-        return None, " | ".join(reasons) if reasons else f"No available {model} printers"
+        return None, " | ".join(reasons) if reasons else f"No available {model} printers{location_suffix}"
 
     def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
         """Get the list of required filament types that are not loaded on the printer.
@@ -995,17 +1004,22 @@ class PrintScheduler:
                 pass  # Don't fail if MQTT fails
         else:
             item.status = "failed"
-            item.error_message = "Failed to send print command"
+            item.error_message = "Failed to send print command to printer"
             item.completed_at = datetime.utcnow()
             await db.commit()
-            logger.error(f"Queue item {item.id}: Failed to start print")
+            logger.error(
+                f"Queue item {item.id}: Failed to start print on {printer.name} ({printer.model}) - "
+                f"printer_manager.start_print() returned False. "
+                f"This may indicate: printer not connected, MQTT error, unsupported model configuration, or firmware issue. "
+                f"Check printer status and backend logs for details."
+            )
 
             # Send failure notification
             await notification_service.on_queue_job_failed(
                 job_name=filename.replace(".gcode.3mf", "").replace(".3mf", ""),
                 printer_id=printer.id,
                 printer_name=printer.name,
-                reason="Failed to send print command",
+                reason="Failed to send print command to printer - check printer connection and status",
                 db=db,
             )
 

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

@@ -192,6 +192,7 @@ class PrinterManager:
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
             access_code=printer.access_code,
+            model=printer.model,
             on_state_change=on_state_change,
             on_print_start=on_print_start,
             on_print_complete=on_print_complete,

+ 125 - 15
backend/app/services/virtual_printer/manager.py

@@ -1,4 +1,11 @@
-"""Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services."""
+"""Virtual Printer Manager - coordinates SSDP, MQTT, and FTP services.
+
+Supports multiple modes:
+- immediate: Archive uploads immediately
+- review: Queue uploads for user review before archiving
+- print_queue: Archive and add to print queue (unassigned)
+- proxy: Transparent TCP proxy to a real printer (for remote slicer access)
+"""
 
 import asyncio
 import logging
@@ -11,6 +18,7 @@ from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.ftp_server import VirtualPrinterFTPServer
 from backend.app.services.virtual_printer.mqtt_server import SimpleMQTTServer
 from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
+from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
 
 logger = logging.getLogger(__name__)
 
@@ -83,11 +91,14 @@ class VirtualPrinterManager:
         self._access_code = ""
         self._mode = "immediate"
         self._model = DEFAULT_VIRTUAL_PRINTER_MODEL
+        self._target_printer_ip = ""  # For proxy mode
+        self._target_printer_serial = ""  # For proxy mode (real printer's serial)
 
         # Service instances
         self._ssdp: VirtualPrinterSSDPServer | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
         self._mqtt: SimpleMQTTServer | None = None
+        self._proxy: SlicerProxyManager | None = None  # For proxy mode
 
         # Background tasks
         self._tasks: list[asyncio.Task] = []
@@ -144,31 +155,49 @@ class VirtualPrinterManager:
         access_code: str = "",
         mode: str = "immediate",
         model: str = "",
+        target_printer_ip: str = "",
+        target_printer_serial: str = "",
     ) -> None:
         """Configure and start/stop virtual printer.
 
         Args:
             enabled: Whether to enable the virtual printer
             access_code: Authentication password for slicer connections
-            mode: Archive mode - 'immediate' or 'queue'
+            mode: Archive mode - 'immediate', 'review', 'print_queue', or 'proxy'
             model: SSDP model code (e.g., 'BL-P001' for X1C)
+            target_printer_ip: Target printer IP for proxy mode
+            target_printer_serial: Target printer serial for proxy mode
         """
-        if enabled and not access_code:
-            raise ValueError("Access code is required when enabling virtual printer")
+        # Proxy mode has different requirements
+        if mode == "proxy":
+            if enabled and not target_printer_ip:
+                raise ValueError("Target printer IP is required for proxy mode")
+            # Access code not required for proxy mode (uses printer's credentials)
+        else:
+            if enabled and not access_code:
+                raise ValueError("Access code is required when enabling virtual printer")
 
         # Validate model if provided
         new_model = model if model and model in VIRTUAL_PRINTER_MODELS else self._model
         model_changed = new_model != self._model
-        old_model = self._model
+        mode_changed = mode != self._mode
+        target_changed = target_printer_ip != self._target_printer_ip
+        serial_changed = target_printer_serial != self._target_printer_serial
+        old_mode = self._mode
 
         logger.debug(
             f"configure() called: enabled={enabled}, self._enabled={self._enabled}, "
-            f"model={model}, new_model={new_model}, old_model={old_model}, model_changed={model_changed}"
+            f"mode={mode}, old_mode={old_mode}, model={model}, new_model={new_model}, "
+            f"target_printer_ip={target_printer_ip}, target_printer_serial={target_printer_serial}"
         )
 
         self._access_code = access_code
         self._mode = mode
         self._model = new_model
+        self._target_printer_ip = target_printer_ip
+        self._target_printer_serial = target_printer_serial
+
+        needs_restart = model_changed or mode_changed or (mode == "proxy" and (target_changed or serial_changed))
 
         if enabled and not self._enabled:
             logger.info("Starting virtual printer (was disabled)")
@@ -176,25 +205,89 @@ class VirtualPrinterManager:
         elif not enabled and self._enabled:
             logger.info("Stopping virtual printer (was enabled)")
             await self._stop()
-        elif enabled and self._enabled and model_changed:
-            # Model changed while running - restart services
-            logger.info(f"Model changed from {old_model} to {new_model}, restarting...")
+        elif enabled and self._enabled and needs_restart:
+            # Configuration changed while running - restart services
+            logger.info(f"Configuration changed (mode={old_mode}→{mode}), restarting...")
             await self._stop()
             # Give time for ports to be released
             await asyncio.sleep(0.5)
             await self._start()
-            logger.info("Virtual printer restarted with new model")
+            logger.info("Virtual printer restarted with new configuration")
         else:
-            logger.debug(
-                f"No state change needed (enabled={enabled}, self._enabled={self._enabled}, model_changed={model_changed})"
-            )
+            logger.debug(f"No state change needed (enabled={enabled}, self._enabled={self._enabled})")
 
         self._enabled = enabled
 
     async def _start(self) -> None:
         """Start all virtual printer services."""
-        logger.info("Starting virtual printer services...")
+        logger.info(f"Starting virtual printer services (mode={self._mode})...")
+
+        # Proxy mode uses different services
+        if self._mode == "proxy":
+            await self._start_proxy_mode()
+            return
+
+        # Standard modes (immediate, review, print_queue) use FTP/MQTT servers
+        await self._start_server_mode()
+
+    async def _start_proxy_mode(self) -> None:
+        """Start virtual printer in proxy mode (TLS terminating relay)."""
+        logger.info(f"Starting proxy mode to {self._target_printer_ip}")
+
+        # In proxy mode, use the REAL printer's serial number
+        # This ensures MQTT topic subscriptions match the real printer's topics
+        proxy_serial = self._target_printer_serial or self.printer_serial
+        logger.info(f"Proxy mode using serial: {proxy_serial}")
+
+        # Update certificate service with the real printer's serial
+        self._cert_service.serial = proxy_serial
+
+        # Regenerate printer cert if needed (CA is preserved)
+        self._cert_service.delete_printer_certificate()
+        cert_path, key_path = self._cert_service.generate_certificates()
+        logger.info(f"Generated certificate for proxy serial: {proxy_serial}")
+
+        # Initialize SSDP for local discovery using the real printer's serial
+        self._ssdp = VirtualPrinterSSDPServer(
+            name=f"{self.PRINTER_NAME} (Proxy)",
+            serial=proxy_serial,
+            model=self._model,
+        )
+
+        # Initialize TLS proxy with our certificates
+        self._proxy = SlicerProxyManager(
+            target_host=self._target_printer_ip,
+            cert_path=cert_path,
+            key_path=key_path,
+            on_activity=self._on_proxy_activity,
+        )
+
+        # Start services as background tasks
+        async def run_with_logging(coro, name):
+            try:
+                await coro
+            except Exception as e:
+                logger.error(f"Virtual printer {name} failed: {e}")
 
+        self._tasks = [
+            asyncio.create_task(
+                run_with_logging(self._ssdp.start(), "SSDP"),
+                name="virtual_printer_ssdp",
+            ),
+            asyncio.create_task(
+                run_with_logging(self._proxy.start(), "Proxy"),
+                name="virtual_printer_proxy",
+            ),
+        ]
+
+        logger.info(
+            f"Virtual printer proxy started: "
+            f"FTP 0.0.0.0:{SlicerProxyManager.LOCAL_FTP_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_FTP_PORT}, "
+            f"MQTT 0.0.0.0:{SlicerProxyManager.LOCAL_MQTT_PORT} → {self._target_printer_ip}:{SlicerProxyManager.PRINTER_MQTT_PORT}"
+        )
+
+    async def _start_server_mode(self) -> None:
+        """Start virtual printer in server mode (FTP/MQTT servers)."""
         # Update certificate service with current serial (based on model)
         current_serial = self.printer_serial
         self._cert_service.serial = current_serial
@@ -247,6 +340,10 @@ class VirtualPrinterManager:
 
         logger.info(f"Virtual printer '{self.PRINTER_NAME}' started (serial: {self.printer_serial})")
 
+    def _on_proxy_activity(self, name: str, message: str) -> None:
+        """Handle proxy activity for logging."""
+        logger.info(f"Proxy {name}: {message}")
+
     async def _stop(self) -> None:
         """Stop all virtual printer services."""
         logger.info("Stopping virtual printer services...")
@@ -264,6 +361,10 @@ class VirtualPrinterManager:
             await self._ssdp.stop()
             self._ssdp = None
 
+        if self._proxy:
+            await self._proxy.stop()
+            self._proxy = None
+
         # Cancel remaining tasks with short timeout
         for task in self._tasks:
             task.cancel()
@@ -487,7 +588,7 @@ class VirtualPrinterManager:
         Returns:
             Status dictionary with enabled, running, mode, etc.
         """
-        return {
+        status = {
             "enabled": self._enabled,
             "running": self.is_running,
             "mode": self._mode,
@@ -498,6 +599,15 @@ class VirtualPrinterManager:
             "pending_files": len(self._pending_files),
         }
 
+        # Add proxy-specific status
+        if self._mode == "proxy":
+            status["target_printer_ip"] = self._target_printer_ip
+            if self._proxy:
+                proxy_status = self._proxy.get_status()
+                status["proxy"] = proxy_status
+
+        return status
+
 
 # Global instance
 virtual_printer_manager = VirtualPrinterManager()

+ 425 - 0
backend/app/services/virtual_printer/tcp_proxy.py

@@ -0,0 +1,425 @@
+"""TLS proxy for slicer-to-printer communication.
+
+This module provides a TLS terminating proxy that forwards data between
+a slicer and a real Bambu printer, enabling remote printing over
+any network connection.
+
+Unlike a transparent TCP proxy, this terminates TLS on both ends:
+- Slicer connects to Bambuddy using Bambuddy's certificate
+- Bambuddy connects to printer using printer's certificate
+- Data is decrypted, forwarded, and re-encrypted
+"""
+
+import asyncio
+import logging
+import ssl
+from collections.abc import Callable
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+class TLSProxy:
+    """TLS terminating proxy that forwards data between client and target.
+
+    This proxy terminates TLS on both ends, allowing the slicer to connect
+    to Bambuddy's certificate while Bambuddy connects to the real printer.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        listen_port: int,
+        target_host: str,
+        target_port: int,
+        server_cert_path: Path,
+        server_key_path: Path,
+        on_connect: Callable[[str], None] | None = None,
+        on_disconnect: Callable[[str], None] | None = None,
+    ):
+        """Initialize the TLS proxy.
+
+        Args:
+            name: Friendly name for logging (e.g., "FTP", "MQTT")
+            listen_port: Port to listen on for incoming connections
+            target_host: Target printer IP/hostname
+            target_port: Target printer port
+            server_cert_path: Path to server certificate (for accepting slicer connections)
+            server_key_path: Path to server private key
+            on_connect: Optional callback when client connects (receives client_id)
+            on_disconnect: Optional callback when client disconnects (receives client_id)
+        """
+        self.name = name
+        self.listen_port = listen_port
+        self.target_host = target_host
+        self.target_port = target_port
+        self.server_cert_path = server_cert_path
+        self.server_key_path = server_key_path
+        self.on_connect = on_connect
+        self.on_disconnect = on_disconnect
+
+        self._server: asyncio.Server | None = None
+        self._running = False
+        self._active_connections: dict[str, tuple[asyncio.Task, asyncio.Task]] = {}
+        self._server_ssl_context: ssl.SSLContext | None = None
+        self._client_ssl_context: ssl.SSLContext | None = None
+
+    def _create_server_ssl_context(self) -> ssl.SSLContext:
+        """Create SSL context for accepting client (slicer) connections."""
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        ctx.load_cert_chain(self.server_cert_path, self.server_key_path)
+        # Allow older TLS versions for compatibility with slicers
+        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        # Don't require client certificates
+        ctx.verify_mode = ssl.CERT_NONE
+        return ctx
+
+    def _create_client_ssl_context(self) -> ssl.SSLContext:
+        """Create SSL context for connecting to printer."""
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+        # Don't verify printer's certificate (self-signed)
+        ctx.check_hostname = False
+        ctx.verify_mode = ssl.CERT_NONE
+        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        return ctx
+
+    async def start(self) -> None:
+        """Start the TLS proxy server."""
+        if self._running:
+            return
+
+        logger.info(
+            f"Starting {self.name} TLS proxy: 0.0.0.0:{self.listen_port} → {self.target_host}:{self.target_port}"
+        )
+
+        try:
+            self._running = True
+
+            # Create SSL contexts
+            self._server_ssl_context = self._create_server_ssl_context()
+            self._client_ssl_context = self._create_client_ssl_context()
+
+            # Start server with TLS
+            self._server = await asyncio.start_server(
+                self._handle_client,
+                "0.0.0.0",
+                self.listen_port,
+                ssl=self._server_ssl_context,
+            )
+
+            logger.info(f"{self.name} TLS proxy listening on port {self.listen_port}")
+
+            async with self._server:
+                await self._server.serve_forever()
+
+        except OSError as e:
+            if e.errno == 98:  # Address already in use
+                logger.error(f"{self.name} proxy port {self.listen_port} is already in use")
+            else:
+                logger.error(f"{self.name} proxy error: {e}")
+        except asyncio.CancelledError:
+            logger.debug(f"{self.name} proxy task cancelled")
+        except Exception as e:
+            logger.error(f"{self.name} proxy error: {e}")
+        finally:
+            await self.stop()
+
+    async def stop(self) -> None:
+        """Stop the TLS proxy server."""
+        logger.info(f"Stopping {self.name} proxy")
+        self._running = False
+
+        # Cancel all active connection tasks
+        for client_id, (task1, task2) in list(self._active_connections.items()):
+            task1.cancel()
+            task2.cancel()
+            if self.on_disconnect:
+                try:
+                    self.on_disconnect(client_id)
+                except Exception:
+                    pass
+
+        self._active_connections.clear()
+
+        if self._server:
+            try:
+                self._server.close()
+                await self._server.wait_closed()
+            except Exception as e:
+                logger.debug(f"Error closing {self.name} proxy server: {e}")
+            self._server = None
+
+    async def _handle_client(
+        self,
+        client_reader: asyncio.StreamReader,
+        client_writer: asyncio.StreamWriter,
+    ) -> None:
+        """Handle a new client connection by proxying to target."""
+        peername = client_writer.get_extra_info("peername")
+        client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
+
+        logger.info(f"{self.name} proxy: client connected from {client_id}")
+
+        if self.on_connect:
+            try:
+                self.on_connect(client_id)
+            except Exception:
+                pass
+
+        # Connect to target printer with TLS
+        try:
+            printer_reader, printer_writer = await asyncio.wait_for(
+                asyncio.open_connection(
+                    self.target_host,
+                    self.target_port,
+                    ssl=self._client_ssl_context,
+                ),
+                timeout=10.0,
+            )
+            logger.info(f"{self.name} proxy: connected to printer {self.target_host}:{self.target_port}")
+        except TimeoutError:
+            logger.error(f"{self.name} proxy: timeout connecting to {self.target_host}:{self.target_port}")
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+        except ssl.SSLError as e:
+            logger.error(f"{self.name} proxy: SSL error connecting to {self.target_host}:{self.target_port}: {e}")
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+        except Exception as e:
+            logger.error(f"{self.name} proxy: failed to connect to {self.target_host}:{self.target_port}: {e}")
+            client_writer.close()
+            await client_writer.wait_closed()
+            return
+
+        # Create bidirectional forwarding tasks
+        client_to_printer = asyncio.create_task(
+            self._forward(client_reader, printer_writer, f"{client_id}→printer"),
+            name=f"{self.name}_c2p_{client_id}",
+        )
+        printer_to_client = asyncio.create_task(
+            self._forward(printer_reader, client_writer, f"printer→{client_id}"),
+            name=f"{self.name}_p2c_{client_id}",
+        )
+
+        self._active_connections[client_id] = (client_to_printer, printer_to_client)
+
+        try:
+            # Wait for either direction to complete (connection closed)
+            done, pending = await asyncio.wait(
+                [client_to_printer, printer_to_client],
+                return_when=asyncio.FIRST_COMPLETED,
+            )
+
+            # Cancel the other direction
+            for task in pending:
+                task.cancel()
+                try:
+                    await task
+                except asyncio.CancelledError:
+                    pass
+
+        except Exception as e:
+            logger.debug(f"{self.name} proxy connection error: {e}")
+        finally:
+            # Clean up
+            self._active_connections.pop(client_id, None)
+
+            for writer in [client_writer, printer_writer]:
+                try:
+                    writer.close()
+                    await writer.wait_closed()
+                except Exception:
+                    pass
+
+            logger.info(f"{self.name} proxy: client {client_id} disconnected")
+
+            if self.on_disconnect:
+                try:
+                    self.on_disconnect(client_id)
+                except Exception:
+                    pass
+
+    async def _forward(
+        self,
+        reader: asyncio.StreamReader,
+        writer: asyncio.StreamWriter,
+        direction: str,
+    ) -> None:
+        """Forward data from reader to writer.
+
+        Args:
+            reader: Source stream (already TLS-decrypted)
+            writer: Destination stream (will be TLS-encrypted by the stream)
+            direction: Description for logging (e.g., "client→printer")
+        """
+        total_bytes = 0
+        try:
+            while self._running:
+                # Read chunk - use reasonable buffer size
+                data = await reader.read(65536)
+                if not data:
+                    # Connection closed
+                    break
+
+                # Forward to destination
+                writer.write(data)
+                await writer.drain()
+
+                total_bytes += len(data)
+                logger.debug(f"{self.name} proxy {direction}: {len(data)} bytes")
+
+        except asyncio.CancelledError:
+            pass
+        except ConnectionResetError:
+            logger.debug(f"{self.name} proxy {direction}: connection reset")
+        except BrokenPipeError:
+            logger.debug(f"{self.name} proxy {direction}: broken pipe")
+        except Exception as e:
+            logger.debug(f"{self.name} proxy {direction} error: {e}")
+
+        logger.debug(f"{self.name} proxy {direction}: total {total_bytes} bytes")
+
+
+class SlicerProxyManager:
+    """Manages FTP and MQTT TLS proxies for a single printer target."""
+
+    # Bambu printer ports
+    PRINTER_FTP_PORT = 990
+    PRINTER_MQTT_PORT = 8883
+
+    # Local listen ports (same as virtual printer)
+    LOCAL_FTP_PORT = 9990
+    LOCAL_MQTT_PORT = 8883
+
+    def __init__(
+        self,
+        target_host: str,
+        cert_path: Path,
+        key_path: Path,
+        on_activity: Callable[[str, str], None] | None = None,
+    ):
+        """Initialize the slicer proxy manager.
+
+        Args:
+            target_host: Target printer IP address
+            cert_path: Path to server certificate
+            key_path: Path to server private key
+            on_activity: Optional callback for activity logging (name, message)
+        """
+        self.target_host = target_host
+        self.cert_path = cert_path
+        self.key_path = key_path
+        self.on_activity = on_activity
+
+        self._ftp_proxy: TLSProxy | None = None
+        self._mqtt_proxy: TLSProxy | None = None
+        self._tasks: list[asyncio.Task] = []
+
+    async def start(self) -> None:
+        """Start FTP and MQTT TLS proxies."""
+        logger.info(f"Starting slicer TLS proxy to {self.target_host}")
+
+        # Create proxies with TLS
+        self._ftp_proxy = TLSProxy(
+            name="FTP",
+            listen_port=self.LOCAL_FTP_PORT,
+            target_host=self.target_host,
+            target_port=self.PRINTER_FTP_PORT,
+            server_cert_path=self.cert_path,
+            server_key_path=self.key_path,
+            on_connect=lambda cid: self._log_activity("FTP", f"connected: {cid}"),
+            on_disconnect=lambda cid: self._log_activity("FTP", f"disconnected: {cid}"),
+        )
+
+        self._mqtt_proxy = TLSProxy(
+            name="MQTT",
+            listen_port=self.LOCAL_MQTT_PORT,
+            target_host=self.target_host,
+            target_port=self.PRINTER_MQTT_PORT,
+            server_cert_path=self.cert_path,
+            server_key_path=self.key_path,
+            on_connect=lambda cid: self._log_activity("MQTT", f"connected: {cid}"),
+            on_disconnect=lambda cid: self._log_activity("MQTT", f"disconnected: {cid}"),
+        )
+
+        # Start as background tasks
+        async def run_with_logging(proxy: TLSProxy) -> None:
+            try:
+                await proxy.start()
+            except Exception as e:
+                logger.error(f"Slicer proxy {proxy.name} failed: {e}")
+
+        self._tasks = [
+            asyncio.create_task(
+                run_with_logging(self._ftp_proxy),
+                name="slicer_proxy_ftp",
+            ),
+            asyncio.create_task(
+                run_with_logging(self._mqtt_proxy),
+                name="slicer_proxy_mqtt",
+            ),
+        ]
+
+        logger.info(f"Slicer TLS proxy started for {self.target_host}")
+
+        # Wait for tasks to complete (they run until cancelled)
+        # This keeps the start() coroutine alive so the parent task doesn't complete
+        try:
+            await asyncio.gather(*self._tasks)
+        except asyncio.CancelledError:
+            logger.debug("Slicer proxy start cancelled")
+
+    async def stop(self) -> None:
+        """Stop all proxies."""
+        logger.info("Stopping slicer proxy")
+
+        # Stop proxies
+        if self._ftp_proxy:
+            await self._ftp_proxy.stop()
+            self._ftp_proxy = None
+
+        if self._mqtt_proxy:
+            await self._mqtt_proxy.stop()
+            self._mqtt_proxy = None
+
+        # Cancel tasks
+        for task in self._tasks:
+            task.cancel()
+
+        if self._tasks:
+            try:
+                await asyncio.wait_for(
+                    asyncio.gather(*self._tasks, return_exceptions=True),
+                    timeout=2.0,
+                )
+            except TimeoutError:
+                logger.debug("Some proxy tasks didn't stop in time")
+
+        self._tasks = []
+        logger.info("Slicer proxy stopped")
+
+    def _log_activity(self, name: str, message: str) -> None:
+        """Log activity via callback if configured."""
+        if self.on_activity:
+            try:
+                self.on_activity(name, message)
+            except Exception:
+                pass
+
+    @property
+    def is_running(self) -> bool:
+        """Check if proxies are running."""
+        return len(self._tasks) > 0 and all(not t.done() for t in self._tasks)
+
+    def get_status(self) -> dict:
+        """Get proxy status."""
+        return {
+            "running": self.is_running,
+            "target_host": self.target_host,
+            "ftp_port": self.LOCAL_FTP_PORT,
+            "mqtt_port": self.LOCAL_MQTT_PORT,
+            "ftp_connections": (len(self._ftp_proxy._active_connections) if self._ftp_proxy else 0),
+            "mqtt_connections": (len(self._mqtt_proxy._active_connections) if self._mqtt_proxy else 0),
+        }

+ 2 - 1
backend/tests/conftest.py

@@ -117,10 +117,11 @@ 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 and auth
+    # Also patch the module-level async_session used by services, auth, and middleware
     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.async_session", test_async_session),
         patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
     ):
         # Seed default groups for tests that need them

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

@@ -689,3 +689,88 @@ class TestChangePasswordAPI:
         )
 
         assert response.status_code == 401
+
+
+class TestAuthMiddlewarePublicRoutes:
+    """Tests for auth middleware public route configuration.
+
+    These routes must be accessible without authentication, even when auth is enabled,
+    because browser elements like <img src> and <video src> don't send Authorization headers.
+    """
+
+    @pytest.fixture
+    async def enabled_auth(self, async_client: AsyncClient):
+        """Enable auth for testing middleware behavior."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "middlewareadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_status_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/auth/status is accessible without auth."""
+        response = await async_client.get("/api/v1/auth/status")
+        assert response.status_code == 200
+        assert "auth_enabled" in response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_login_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/auth/login is accessible without auth."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "middlewareadmin", "password": "adminpassword123"},
+        )
+        # Should not return 401 (unauthorized) - it should either succeed or return
+        # a different error (like 400 for wrong credentials)
+        assert response.status_code != 401 or "token" in response.json()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_setup_is_public(self, async_client: AsyncClient):
+        """Verify /api/v1/auth/setup is accessible without auth (needed for setup/recovery)."""
+        # Don't enable auth first - test that setup endpoint itself is accessible
+        response = await async_client.post(
+            "/api/v1/auth/setup",
+            json={"auth_enabled": False},
+        )
+        # Should not be 401
+        assert response.status_code != 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_updates_version_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/updates/version is accessible without auth."""
+        response = await async_client.get("/api/v1/updates/version")
+        # Should not be 401
+        assert response.status_code != 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_protected_route_requires_auth(self, async_client: AsyncClient, enabled_auth):
+        """Verify non-public routes return 401 without token."""
+        response = await async_client.get("/api/v1/printers/")
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_protected_route_works_with_token(self, async_client: AsyncClient, enabled_auth):
+        """Verify non-public routes work with valid token."""
+        # Login to get token
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "middlewareadmin", "password": "adminpassword123"},
+        )
+        token = login_response.json()["access_token"]
+
+        # Access protected route
+        response = await async_client.get(
+            "/api/v1/printers/",
+            headers={"Authorization": f"Bearer {token}"},
+        )
+        assert response.status_code == 200

+ 173 - 0
backend/tests/integration/test_endpoint_auth.py

@@ -0,0 +1,173 @@
+"""Integration tests for API endpoint authentication.
+
+Tests that verify endpoints properly enforce authentication when auth is enabled,
+and allow access when auth is disabled (CVE-2026-25505 fix verification).
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestEndpointAuthenticationEnforcement:
+    """Tests that endpoints enforce authentication when auth is enabled."""
+
+    @pytest.fixture
+    async def user_factory(self, db_session):
+        """Factory to create test users."""
+
+        async def _create_user(**kwargs):
+            from passlib.hash import bcrypt
+
+            from backend.app.models.user import User
+
+            defaults = {
+                "username": "testuser",
+                "password_hash": bcrypt.hash("testpass123"),
+                "is_admin": False,
+            }
+            defaults.update(kwargs)
+
+            user = User(**defaults)
+            db_session.add(user)
+            await db_session.commit()
+            await db_session.refresh(user)
+            return user
+
+        return _create_user
+
+    @pytest.fixture
+    async def admin_user(self, user_factory, db_session):
+        """Create an admin user for testing."""
+        from sqlalchemy import select
+
+        from backend.app.models.group import Group
+
+        # Get or create admin group
+        result = await db_session.execute(select(Group).where(Group.name == "Administrators"))
+        admin_group = result.scalar_one_or_none()
+
+        user = await user_factory(username="admin", is_admin=True)
+        if admin_group:
+            user.groups.append(admin_group)
+            await db_session.commit()
+        return user
+
+    @pytest.fixture
+    async def auth_token(self, admin_user, async_client: AsyncClient):
+        """Get a valid auth token for the admin user."""
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "admin", "password": "testpass123"},
+        )
+        if response.status_code == 200:
+            return response.json().get("access_token")
+        return None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_filaments_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
+        """Verify filaments list is accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/filaments/")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_links_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
+        """Verify external links list is accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/external-links/")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_notifications_list_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
+        """Verify notifications list is accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/notifications/")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_maintenance_types_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
+        """Verify maintenance types is accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/maintenance/types")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_system_info_accessible_without_auth_when_disabled(self, async_client: AsyncClient):
+        """Verify system info is accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            response = await async_client.get("/api/v1/system/info")
+            assert response.status_code == 200
+
+
+class TestImageEndpointsPublicAccess:
+    """Tests that image endpoints remain accessible without auth.
+
+    These endpoints serve images via <img> tags which cannot send Authorization headers.
+    """
+
+    @pytest.fixture
+    async def link_with_icon(self, db_session):
+        """Create an external link with a custom icon for testing."""
+        from backend.app.models.external_link import ExternalLink
+
+        link = ExternalLink(
+            name="Test Link",
+            url="https://example.com",
+            icon="Link",
+            sort_order=0,
+            custom_icon=None,  # No custom icon set
+        )
+        db_session.add(link)
+        await db_session.commit()
+        await db_session.refresh(link)
+        return link
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_external_link_icon_returns_404_when_no_icon(self, async_client: AsyncClient, link_with_icon):
+        """Verify icon endpoint returns 404 (not 401) when no icon is set.
+
+        This confirms the endpoint doesn't require auth - a 401 would indicate
+        auth is being enforced, but 404 means the endpoint is accessible but
+        no icon exists.
+        """
+        response = await async_client.get(f"/api/v1/external-links/{link_with_icon.id}/icon")
+        # Should be 404 (no icon set), not 401 (unauthorized)
+        assert response.status_code == 404
+        assert "No custom icon set" in response.json().get("detail", "")
+
+
+class TestAuthenticationPatterns:
+    """Tests for authentication helper functions and patterns."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_require_permission_if_auth_enabled_allows_access_when_disabled(self, async_client: AsyncClient):
+        """Verify require_permission_if_auth_enabled allows access when auth disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            # Test a protected endpoint
+            response = await async_client.get("/api/v1/filaments/")
+            assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_multiple_endpoints_accessible_when_auth_disabled(self, async_client: AsyncClient):
+        """Verify multiple protected endpoints are accessible when auth is disabled."""
+        with patch("backend.app.core.auth.is_auth_enabled", return_value=False):
+            endpoints = [
+                "/api/v1/filaments/",
+                "/api/v1/external-links/",
+                "/api/v1/notifications/",
+                "/api/v1/maintenance/types",
+            ]
+
+            for endpoint in endpoints:
+                response = await async_client.get(endpoint)
+                assert response.status_code == 200, f"Endpoint {endpoint} should be accessible"

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

@@ -848,3 +848,210 @@ class TestLibraryPathHelpers:
 
         assert to_absolute_path(None) is None
         assert to_absolute_path("") is None
+
+
+class TestLibraryPermissions:
+    """Tests for library permission enforcement."""
+
+    @pytest.fixture
+    async def auth_setup(self, db_session):
+        """Set up auth with users of different permission levels."""
+        from backend.app.core.auth import create_access_token, get_password_hash
+        from backend.app.models.group import Group
+        from backend.app.models.settings import Settings
+        from backend.app.models.user import User
+
+        # Enable auth
+        settings = Settings(key="auth_enabled", value="true")
+        db_session.add(settings)
+        await db_session.commit()
+
+        # Groups are auto-seeded during db init, but we need to commit them
+        await db_session.commit()
+
+        # Get groups
+        from sqlalchemy import select
+
+        admin_group = (await db_session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
+        operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
+        viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
+
+        password_hash = get_password_hash("password")
+
+        # Create users
+        admin_user = User(username="admin_lib", password_hash=password_hash, role="admin", is_active=True)
+        admin_user.groups.append(admin_group)
+
+        operator_user = User(username="operator_lib", password_hash=password_hash, is_active=True)
+        operator_user.groups.append(operator_group)
+
+        viewer_user = User(username="viewer_lib", password_hash=password_hash, is_active=True)
+        viewer_user.groups.append(viewer_group)
+
+        db_session.add_all([admin_user, operator_user, viewer_user])
+        await db_session.commit()
+
+        # Create tokens
+        admin_token = create_access_token(data={"sub": admin_user.username})
+        operator_token = create_access_token(data={"sub": operator_user.username})
+        viewer_token = create_access_token(data={"sub": viewer_user.username})
+
+        return {
+            "admin_user": admin_user,
+            "operator_user": operator_user,
+            "viewer_user": viewer_user,
+            "admin_token": admin_token,
+            "operator_token": operator_token,
+            "viewer_token": viewer_token,
+        }
+
+    @pytest.fixture
+    async def test_file(self, db_session, auth_setup):
+        """Create a test file owned by the operator user."""
+        from backend.app.models.library import LibraryFile
+
+        operator_user = auth_setup["operator_user"]
+        lib_file = LibraryFile(
+            filename="test.txt",
+            file_path="data/archive/library/files/test.txt",
+            file_type="txt",
+            file_size=100,
+            created_by_id=operator_user.id,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+        return lib_file
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_requires_library_read(self, async_client: AsyncClient, db_session, auth_setup):
+        """Verify list_files requires library:read permission."""
+        viewer_token = auth_setup["viewer_token"]
+
+        # Viewers have library:read, should succeed
+        response = await async_client.get("/api/v1/library/files", headers={"Authorization": f"Bearer {viewer_token}"})
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_denied_without_permission(self, async_client: AsyncClient, db_session):
+        """Verify list_files denied without auth when auth is enabled."""
+        from backend.app.models.settings import Settings
+
+        # Enable auth
+        settings = Settings(key="auth_enabled", value="true")
+        db_session.add(settings)
+        await db_session.commit()
+
+        # Request without token should fail
+        response = await async_client.get("/api/v1/library/files")
+        assert response.status_code == 401
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_file_own_by_owner(self, async_client: AsyncClient, db_session, auth_setup, test_file):
+        """Verify operator can delete their own files."""
+        from pathlib import Path
+
+        # Create actual file on disk so delete doesn't fail
+        from backend.app.core.config import settings as app_settings
+
+        file_path = Path(app_settings.base_dir) / test_file.file_path
+        file_path.parent.mkdir(parents=True, exist_ok=True)
+        file_path.write_text("test content")
+
+        operator_token = auth_setup["operator_token"]
+
+        response = await async_client.delete(
+            f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_file_own_denied_for_others_file(self, async_client: AsyncClient, db_session, auth_setup):
+        """Verify operator cannot delete files owned by others."""
+        # Create another operator user with a file
+        from sqlalchemy import select
+
+        from backend.app.core.auth import create_access_token
+        from backend.app.models.group import Group
+        from backend.app.models.library import LibraryFile
+        from backend.app.models.user import User
+
+        operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
+
+        from backend.app.core.auth import get_password_hash as get_pw_hash
+
+        other_user = User(username="other_op", password_hash=get_pw_hash("password"), is_active=True)
+        other_user.groups.append(operator_group)
+        db_session.add(other_user)
+        await db_session.commit()
+        await db_session.refresh(other_user)
+
+        # Create file owned by other user
+        other_file = LibraryFile(
+            filename="other.txt",
+            file_path="data/archive/library/files/other.txt",
+            file_type="txt",
+            file_size=100,
+            created_by_id=other_user.id,
+        )
+        db_session.add(other_file)
+        await db_session.commit()
+        await db_session.refresh(other_file)
+
+        # Original operator should not be able to delete it
+        operator_token = auth_setup["operator_token"]
+        response = await async_client.delete(
+            f"/api/v1/library/files/{other_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
+        )
+        assert response.status_code == 403
+        assert "your own files" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_file_admin_can_delete_any(self, async_client: AsyncClient, db_session, auth_setup):
+        """Verify admin can delete any file."""
+        from pathlib import Path
+
+        from backend.app.core.config import settings as app_settings
+        from backend.app.models.library import LibraryFile
+
+        # Create file owned by operator
+        operator_user = auth_setup["operator_user"]
+        lib_file = LibraryFile(
+            filename="admin_can_delete.txt",
+            file_path="data/archive/library/files/admin_can_delete.txt",
+            file_type="txt",
+            file_size=100,
+            created_by_id=operator_user.id,
+        )
+        db_session.add(lib_file)
+        await db_session.commit()
+        await db_session.refresh(lib_file)
+
+        # Create actual file on disk
+        file_path = Path(app_settings.base_dir) / lib_file.file_path
+        file_path.parent.mkdir(parents=True, exist_ok=True)
+        file_path.write_text("test content")
+
+        # Admin should be able to delete it
+        admin_token = auth_setup["admin_token"]
+        response = await async_client.delete(
+            f"/api/v1/library/files/{lib_file.id}", headers={"Authorization": f"Bearer {admin_token}"}
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_viewer_cannot_delete_files(self, async_client: AsyncClient, db_session, auth_setup, test_file):
+        """Verify viewer cannot delete any files."""
+        viewer_token = auth_setup["viewer_token"]
+
+        response = await async_client.delete(
+            f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {viewer_token}"}
+        )
+        # Viewers don't have delete_own or delete_all permissions
+        assert response.status_code == 403

+ 8 - 2
backend/tests/integration/test_ownership_permissions.py

@@ -700,7 +700,10 @@ class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
         assert response.status_code == 204
 
         # Verify archive still exists but is now ownerless
-        archive_response = await async_client.get(f"/api/v1/archives/{archive_id}")
+        archive_response = await async_client.get(
+            f"/api/v1/archives/{archive_id}",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
         assert archive_response.status_code == 200
         assert archive_response.json()["created_by_id"] is None
 
@@ -736,5 +739,8 @@ class TestUserItemsCountAndDeletion(TestOwnershipPermissionsSetup):
         assert response.status_code == 204
 
         # Verify archive was deleted
-        archive_response = await async_client.get(f"/api/v1/archives/{archive_id}")
+        archive_response = await async_client.get(
+            f"/api/v1/archives/{archive_id}",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
         assert archive_response.status_code == 404

+ 213 - 0
backend/tests/integration/test_print_queue_api.py

@@ -963,3 +963,216 @@ class TestBulkUpdateEndpoint:
         )
         assert response.status_code == 400
         assert "printer not found" in response.json()["detail"].lower()
+
+
+class TestTargetLocationFeature:
+    """Tests for queue items with target_location (Issue #220)."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Location Test Printer {counter}",
+                "ip_address": f"192.168.1.{50 + counter}",
+                "serial_number": f"TESTLOC{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"location_test_{counter}.3mf",
+                "print_name": f"Location Test Print {counter}",
+                "file_path": f"/tmp/location_test_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"lochash{counter:08d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items."""
+        _counter = [0]
+
+        async def _create_queue_item(**kwargs):
+            from backend.app.models.print_queue import PrintQueueItem
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            if "printer_id" not in kwargs and "target_model" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": counter,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_target_location(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify item can be added with target_model and target_location."""
+        # Create a printer with model X1C so the API can validate
+        await printer_factory(model="X1C", location="Office")
+        archive = await archive_factory()
+
+        data = {
+            "target_model": "X1C",
+            "target_location": "Workbench",
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_model"] == "X1C"
+        assert result["target_location"] == "Workbench"
+        assert result["printer_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_location_without_model_ignored(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify target_location without target_model is allowed (location is just ignored)."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        data = {
+            "printer_id": printer.id,
+            "target_location": "Workbench",  # This gets ignored since printer_id is set
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        # The API accepts this but the location is only used with target_model
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+        # Location may or may not be stored since it's meaningless without target_model
+        # The important thing is the request succeeds
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_item_target_location_in_response(
+        self, async_client: AsyncClient, queue_item_factory, db_session
+    ):
+        """Verify target_location is returned in queue item response."""
+        item = await queue_item_factory(
+            printer_id=None,
+            target_model="X1C",
+            target_location="Workshop",
+        )
+
+        response = await async_client.get(f"/api/v1/queue/{item.id}")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_model"] == "X1C"
+        assert result["target_location"] == "Workshop"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_list_includes_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify target_location is included in queue list."""
+        await queue_item_factory(
+            printer_id=None,
+            target_model="P1S",
+            target_location="Garage",
+        )
+
+        response = await async_client.get("/api/v1/queue/")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) >= 1
+
+        # Find our item
+        our_item = next((i for i in items if i["target_location"] == "Garage"), None)
+        assert our_item is not None
+        assert our_item["target_model"] == "P1S"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_queue_item_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify target_location can be updated on existing queue item."""
+        item = await queue_item_factory(
+            printer_id=None,
+            target_model="X1C",
+            target_location="Office",
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"target_location": "Basement"},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_location"] == "Basement"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
+        """Verify target_location can be cleared (set to None)."""
+        item = await queue_item_factory(
+            printer_id=None,
+            target_model="X1C",
+            target_location="Office",
+        )
+
+        # Note: Setting to empty string should clear it
+        response = await async_client.patch(
+            f"/api/v1/queue/{item.id}",
+            json={"target_location": None},
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["target_location"] is None

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

@@ -260,6 +260,83 @@ class TestSpoolmanAPI:
         assert len(data) == 1
         assert data[0]["id"] == 2  # Only unlinked spool
 
+    # =========================================================================
+    # Linked Spools Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_not_enabled(self, async_client: AsyncClient):
+        """Verify get linked spools fails when not enabled."""
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify get linked spools returns map of tag -> spool_id."""
+        # Mock spool with extra.tag (linked)
+        mock_spool = {
+            "id": 42,
+            "remaining_weight": 800,
+            "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        assert "linked" in data
+        assert isinstance(data["linked"], dict)
+        # Tag should be uppercase and stripped of quotes
+        assert "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" in data["linked"]
+        assert data["linked"]["A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"] == 42
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_excludes_unlinked(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify unlinked spools (without tag) are excluded."""
+        # Mock spool with tag (linked)
+        mock_spool_linked = {
+            "id": 1,
+            "extra": {"tag": '"ABC12345678901234567890123456789A"'},
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
+        }
+        # Mock spool without tag (unlinked)
+        mock_spool_unlinked = {
+            "id": 2,
+            "extra": {},
+            "filament": {"id": 2, "name": "PLA Blue", "material": "PLA"},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_linked, mock_spool_unlinked])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data["linked"]) == 1  # Only linked spool
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_empty_tag_excluded(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify spools with empty tag (JSON-encoded empty string) are excluded."""
+        # Mock spool with empty JSON-encoded tag
+        mock_spool = {
+            "id": 1,
+            "extra": {"tag": '""'},  # JSON-encoded empty string
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data["linked"]) == 0  # Empty tag should be excluded
+
     # =========================================================================
     # Link Spool Tests
     # =========================================================================

+ 238 - 0
backend/tests/unit/services/test_bambu_cloud.py

@@ -0,0 +1,238 @@
+"""Tests for Bambu Cloud service - TOTP and email verification flows."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.bambu_cloud import BambuCloudService
+
+
+class TestBambuCloudLogin:
+    """Test login flow detection (email vs TOTP)."""
+
+    @pytest.fixture
+    def cloud_service(self):
+        """Create a BambuCloudService instance."""
+        return BambuCloudService()
+
+    @pytest.mark.asyncio
+    async def test_login_detects_email_verification(self, cloud_service):
+        """When loginType is verifyCode, should return email verification type."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "loginType": "verifyCode",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.login_request("test@example.com", "password")
+
+            assert result["success"] is False
+            assert result["needs_verification"] is True
+            assert result["verification_type"] == "email"
+            assert result["tfa_key"] is None
+            assert "email" in result["message"].lower()
+
+    @pytest.mark.asyncio
+    async def test_login_detects_totp(self, cloud_service):
+        """When loginType is tfa, should return TOTP verification type with tfaKey."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "loginType": "tfa",
+            "tfaKey": "test-tfa-key-123",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.login_request("test@example.com", "password")
+
+            assert result["success"] is False
+            assert result["needs_verification"] is True
+            assert result["verification_type"] == "totp"
+            assert result["tfa_key"] == "test-tfa-key-123"
+            assert "authenticator" in result["message"].lower()
+
+    @pytest.mark.asyncio
+    async def test_login_direct_success(self, cloud_service):
+        """When accessToken is returned directly, should succeed without verification."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "accessToken": "test-access-token",
+            "refreshToken": "test-refresh-token",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.login_request("test@example.com", "password")
+
+            assert result["success"] is True
+            assert result["needs_verification"] is False
+            assert cloud_service.access_token == "test-access-token"
+
+    @pytest.mark.asyncio
+    async def test_login_failure(self, cloud_service):
+        """When login fails, should return error message."""
+        mock_response = MagicMock()
+        mock_response.status_code = 401
+        mock_response.json.return_value = {
+            "message": "Invalid credentials",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.login_request("test@example.com", "wrong-password")
+
+            assert result["success"] is False
+            assert result["needs_verification"] is False
+            assert "Invalid credentials" in result["message"]
+
+
+class TestBambuCloudEmailVerification:
+    """Test email verification flow."""
+
+    @pytest.fixture
+    def cloud_service(self):
+        """Create a BambuCloudService instance."""
+        return BambuCloudService()
+
+    @pytest.mark.asyncio
+    async def test_verify_code_success(self, cloud_service):
+        """When email code is correct, should return success with token."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.json.return_value = {
+            "accessToken": "test-access-token",
+            "refreshToken": "test-refresh-token",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.verify_code("test@example.com", "123456")
+
+            assert result["success"] is True
+            assert cloud_service.access_token == "test-access-token"
+
+    @pytest.mark.asyncio
+    async def test_verify_code_failure(self, cloud_service):
+        """When email code is incorrect, should return failure."""
+        mock_response = MagicMock()
+        mock_response.status_code = 400
+        mock_response.json.return_value = {
+            "message": "Invalid verification code",
+        }
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.verify_code("test@example.com", "000000")
+
+            assert result["success"] is False
+            assert "Invalid" in result["message"] or "Verification failed" in result["message"]
+
+
+class TestBambuCloudTOTPVerification:
+    """Test TOTP verification flow."""
+
+    @pytest.fixture
+    def cloud_service(self):
+        """Create a BambuCloudService instance."""
+        return BambuCloudService()
+
+    @pytest.mark.asyncio
+    async def test_verify_totp_success(self, cloud_service):
+        """When TOTP code is correct, should return success with token."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.text = '{"token": "test-access-token"}'
+        mock_response.json.return_value = {
+            "token": "test-access-token",
+        }
+        mock_response.cookies = {}
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.verify_totp("test-tfa-key", "123456")
+
+            assert result["success"] is True
+            assert cloud_service.access_token == "test-access-token"
+
+    @pytest.mark.asyncio
+    async def test_verify_totp_uses_correct_endpoint(self, cloud_service):
+        """TOTP verification should use bambulab.com, not api.bambulab.com."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.text = '{"token": "test-token"}'
+        mock_response.json.return_value = {"token": "test-token"}
+        mock_response.cookies = {}
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            await cloud_service.verify_totp("test-tfa-key", "123456")
+
+            # Check the URL used
+            call_args = mock_post.call_args
+            url = call_args[0][0]
+            assert "bambulab.com/api/sign-in/tfa" in url
+            assert "api.bambulab.com" not in url
+
+    @pytest.mark.asyncio
+    async def test_verify_totp_empty_response(self, cloud_service):
+        """When TOTP returns empty response, should handle gracefully."""
+        mock_response = MagicMock()
+        mock_response.status_code = 400
+        mock_response.text = ""
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.verify_totp("test-tfa-key", "123456")
+
+            assert result["success"] is False
+            assert "empty response" in result["message"].lower()
+
+    @pytest.mark.asyncio
+    async def test_verify_totp_cloudflare_blocked(self, cloud_service):
+        """When Cloudflare blocks request, should handle gracefully."""
+        mock_response = MagicMock()
+        mock_response.status_code = 403
+        mock_response.text = "<!DOCTYPE html><html><head><title>Just a moment...</title>"
+        # json() raises an error when response is HTML
+        mock_response.json.side_effect = ValueError("No JSON")
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            result = await cloud_service.verify_totp("test-tfa-key", "123456")
+
+            assert result["success"] is False
+            assert "Invalid response" in result["message"]
+
+    @pytest.mark.asyncio
+    async def test_verify_totp_includes_browser_headers(self, cloud_service):
+        """TOTP verification should include browser-like headers to bypass Cloudflare."""
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.text = '{"token": "test-token"}'
+        mock_response.json.return_value = {"token": "test-token"}
+        mock_response.cookies = {}
+
+        with patch.object(cloud_service._client, "post", new_callable=AsyncMock) as mock_post:
+            mock_post.return_value = mock_response
+
+            await cloud_service.verify_totp("test-tfa-key", "123456")
+
+            # Check headers include User-Agent
+            call_args = mock_post.call_args
+            headers = call_args[1]["headers"]
+            assert "User-Agent" in headers
+            assert "Mozilla" in headers["User-Agent"]

+ 104 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -439,3 +439,107 @@ class TestCertificateService:
 
         assert cert_path.exists()
         assert key_path.exists()
+
+
+class TestSlicerProxyManager:
+    """Tests for SlicerProxyManager (proxy mode)."""
+
+    @pytest.fixture
+    def proxy_manager(self, tmp_path):
+        """Create a SlicerProxyManager instance."""
+        from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
+
+        # Create dummy cert files
+        cert_path = tmp_path / "cert.pem"
+        key_path = tmp_path / "key.pem"
+        cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
+        # Split string to avoid pre-commit hook false positive on test data
+        key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
+
+        return SlicerProxyManager(
+            target_host="192.168.1.100",
+            cert_path=cert_path,
+            key_path=key_path,
+        )
+
+    def test_proxy_manager_initializes_ports(self, proxy_manager):
+        """Verify proxy manager has correct port constants."""
+        assert proxy_manager.LOCAL_FTP_PORT == 9990
+        assert proxy_manager.LOCAL_MQTT_PORT == 8883
+        assert proxy_manager.PRINTER_FTP_PORT == 990
+        assert proxy_manager.PRINTER_MQTT_PORT == 8883
+
+    def test_proxy_manager_stores_target_host(self, proxy_manager):
+        """Verify proxy manager stores target host."""
+        assert proxy_manager.target_host == "192.168.1.100"
+
+    def test_get_status_before_start(self, proxy_manager):
+        """Verify get_status returns zeros before start."""
+        status = proxy_manager.get_status()
+
+        assert status["running"] is False
+        assert status["ftp_connections"] == 0
+        assert status["mqtt_connections"] == 0
+
+
+class TestVirtualPrinterManagerProxyMode:
+    """Tests for VirtualPrinterManager proxy mode."""
+
+    @pytest.fixture
+    def manager(self):
+        """Create a VirtualPrinterManager instance."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterManager
+
+        return VirtualPrinterManager()
+
+    @pytest.mark.asyncio
+    async def test_configure_proxy_mode_requires_target_ip(self, manager):
+        """Verify proxy mode requires target_printer_ip."""
+        with pytest.raises(ValueError, match="Target printer IP is required"):
+            await manager.configure(
+                enabled=True,
+                mode="proxy",
+                target_printer_ip="",  # Empty target IP
+            )
+
+    @pytest.mark.asyncio
+    async def test_configure_proxy_mode_does_not_require_access_code(self, manager):
+        """Verify proxy mode does not require access code (uses real printer's)."""
+        manager._start = AsyncMock()
+
+        # Should not raise - proxy mode doesn't need access code
+        await manager.configure(
+            enabled=True,
+            mode="proxy",
+            target_printer_ip="192.168.1.100",
+        )
+
+        assert manager._mode == "proxy"
+        assert manager._target_printer_ip == "192.168.1.100"
+
+    def test_get_status_proxy_mode_includes_proxy_fields(self, manager):
+        """Verify get_status includes proxy-specific fields in proxy mode."""
+        manager._enabled = True
+        manager._mode = "proxy"
+        manager._target_printer_ip = "192.168.1.100"
+        manager._tasks = [MagicMock(done=MagicMock(return_value=False))]
+
+        # Create a mock proxy with get_status
+        mock_proxy = MagicMock()
+        mock_proxy.get_status.return_value = {
+            "running": True,
+            "ftp_port": 9990,
+            "mqtt_port": 8883,
+            "ftp_connections": 1,
+            "mqtt_connections": 2,
+            "target_host": "192.168.1.100",
+        }
+        manager._proxy = mock_proxy
+
+        status = manager.get_status()
+
+        assert status["mode"] == "proxy"
+        assert status["target_printer_ip"] == "192.168.1.100"
+        assert "proxy" in status
+        assert status["proxy"]["ftp_connections"] == 1
+        assert status["proxy"]["mqtt_connections"] == 2

+ 12 - 28
docker-publish.sh

@@ -2,12 +2,13 @@
 # Build and push multi-architecture Docker image to GitHub Container Registry
 #
 # Usage:
-#   ./scripts/docker-publish.sh [version] [--parallel]
+#   ./docker-publish.sh [version] [--parallel]
 #
 # Examples:
-#   ./scripts/docker-publish.sh 0.1.6           # Sequential build (default)
-#   ./scripts/docker-publish.sh 0.1.6 --parallel # Build both archs simultaneously
-#   ./scripts/docker-publish.sh 0.1.6-beta      # Pre-release (no latest tag)
+#   ./docker-publish.sh 0.1.7b            # Sequential build (default)
+#   ./docker-publish.sh 0.1.7b --parallel # Build both archs simultaneously
+#
+# Note: All versions are also tagged as 'latest'
 #
 # Prerequisites:
 #   1. Log in to ghcr.io first:
@@ -82,11 +83,7 @@ if ! grep -q "ghcr.io" ~/.docker/config.json 2>/dev/null; then
     echo ""
 fi
 
-# Determine if this is a release version (includes betas for now)
-IS_RELEASE=false
-if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(b[0-9]+)?$ ]]; then
-    IS_RELEASE=true
-fi
+# Always tag as latest (in addition to version tag)
 
 # Setup buildx builder if not exists
 echo -e "${BLUE}[1/4] Setting up Docker Buildx...${NC}"
@@ -113,14 +110,9 @@ if ! docker buildx inspect --bootstrap | grep -q "linux/arm64"; then
     docker run --privileged --rm tonistiigi/binfmt --install all
 fi
 
-# Build tags
-TAGS="-t ${FULL_IMAGE}:${VERSION}"
-if [ "$IS_RELEASE" = true ]; then
-    TAGS="$TAGS -t ${FULL_IMAGE}:latest"
-    echo -e "${BLUE}[3/4] Building and pushing (version + latest)...${NC}"
-else
-    echo -e "${BLUE}[3/4] Building and pushing (version only, no latest)...${NC}"
-fi
+# Build tags (always include latest)
+TAGS="-t ${FULL_IMAGE}:${VERSION} -t ${FULL_IMAGE}:latest"
+echo -e "${BLUE}[3/4] Building and pushing (version + latest)...${NC}"
 
 # Common build args (no cache to ensure clean builds)
 BUILD_ARGS="--provenance=false --sbom=false --no-cache --pull"
@@ -160,19 +152,13 @@ if [ "$PARALLEL" = true ]; then
     wait $PID_AMD64
     wait $PID_ARM64
 
-    # Create and push multi-arch manifest
+    # Create and push multi-arch manifest (version + latest)
     echo -e "${BLUE}Creating multi-arch manifest...${NC}"
     docker buildx imagetools create \
         -t "${FULL_IMAGE}:${VERSION}" \
+        -t "${FULL_IMAGE}:latest" \
         "${FULL_IMAGE}:${VERSION}-amd64" \
         "${FULL_IMAGE}:${VERSION}-arm64"
-
-    if [ "$IS_RELEASE" = true ]; then
-        docker buildx imagetools create \
-            -t "${FULL_IMAGE}:latest" \
-            "${FULL_IMAGE}:${VERSION}-amd64" \
-            "${FULL_IMAGE}:${VERSION}-arm64"
-    fi
 else
     # Sequential build (default): Build both platforms in one command
     echo -e "${YELLOW}Building sequentially with ${CPU_COUNT} cores (no cache)...${NC}"
@@ -192,9 +178,7 @@ echo -e "${GREEN}================================================${NC}"
 echo -e "${GREEN}✓ Successfully pushed multi-arch image:${NC}"
 echo -e "${GREEN}================================================${NC}"
 echo "  - ${FULL_IMAGE}:${VERSION}"
-if [ "$IS_RELEASE" = true ]; then
-    echo "  - ${FULL_IMAGE}:latest"
-fi
+echo "  - ${FULL_IMAGE}:latest"
 echo ""
 echo -e "${BLUE}Supported platforms:${NC}"
 echo "  - linux/amd64 (Intel/AMD servers, desktops)"

BIN
docs/images/proxy-mode-diagram.png


+ 401 - 0
frontend/docs/create_proxy_diagram.py

@@ -0,0 +1,401 @@
+#!/usr/bin/env python3
+"""
+Create a professional network architecture diagram for Bambuddy Virtual Printer Proxy Mode.
+Following the Signal Flow design philosophy.
+"""
+
+from PIL import Image, ImageDraw, ImageFont
+from pathlib import Path
+
+# Canvas dimensions
+WIDTH = 1400
+HEIGHT = 700
+
+# Colors - Signal Flow palette
+BG_COLOR = (18, 18, 22)  # Near black
+CONTAINER_BG = (28, 28, 35)  # Slightly lighter
+CONTAINER_BORDER = (50, 50, 60)  # Subtle border
+BAMBU_GREEN = (0, 174, 66)  # #00AE42
+BAMBU_GREEN_DIM = (0, 120, 45)  # Dimmer green for accents
+TEXT_PRIMARY = (240, 240, 245)  # Near white
+TEXT_SECONDARY = (140, 140, 150)  # Gray
+TEXT_LABEL = (100, 100, 110)  # Darker gray for small labels
+INTERNET_COLOR = (80, 80, 95)  # Cloud color
+TLS_BADGE_BG = (35, 55, 45)  # Dark green for TLS badges
+LOCK_COLOR = BAMBU_GREEN
+
+# Font paths
+FONT_DIR = Path("/opt/claude/.claude/plugins/cache/anthropic-agent-skills/document-skills/f23222824449/skills/canvas-design/canvas-fonts")
+
+def load_fonts():
+    """Load fonts for the diagram."""
+    fonts = {}
+    try:
+        fonts['title'] = ImageFont.truetype(str(FONT_DIR / "InstrumentSans-Bold.ttf"), 28)
+        fonts['heading'] = ImageFont.truetype(str(FONT_DIR / "InstrumentSans-Bold.ttf"), 18)
+        fonts['label'] = ImageFont.truetype(str(FONT_DIR / "InstrumentSans-Regular.ttf"), 14)
+        fonts['small'] = ImageFont.truetype(str(FONT_DIR / "InstrumentSans-Regular.ttf"), 12)
+        fonts['port'] = ImageFont.truetype(str(FONT_DIR / "JetBrainsMono-Bold.ttf"), 13)
+        fonts['port_small'] = ImageFont.truetype(str(FONT_DIR / "JetBrainsMono-Regular.ttf"), 11)
+        fonts['tls'] = ImageFont.truetype(str(FONT_DIR / "JetBrainsMono-Bold.ttf"), 10)
+    except Exception as e:
+        print(f"Font loading error: {e}")
+        # Fallback to default
+        fonts['title'] = ImageFont.load_default()
+        fonts['heading'] = ImageFont.load_default()
+        fonts['label'] = ImageFont.load_default()
+        fonts['small'] = ImageFont.load_default()
+        fonts['port'] = ImageFont.load_default()
+        fonts['port_small'] = ImageFont.load_default()
+        fonts['tls'] = ImageFont.load_default()
+    return fonts
+
+def draw_rounded_rect(draw, xy, radius, fill=None, outline=None, width=1):
+    """Draw a rounded rectangle."""
+    x1, y1, x2, y2 = xy
+
+    if fill:
+        # Fill
+        draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill)
+        draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill)
+        draw.ellipse([x1, y1, x1 + 2*radius, y1 + 2*radius], fill=fill)
+        draw.ellipse([x2 - 2*radius, y1, x2, y1 + 2*radius], fill=fill)
+        draw.ellipse([x1, y2 - 2*radius, x1 + 2*radius, y2], fill=fill)
+        draw.ellipse([x2 - 2*radius, y2 - 2*radius, x2, y2], fill=fill)
+
+    if outline:
+        # Outline
+        draw.arc([x1, y1, x1 + 2*radius, y1 + 2*radius], 180, 270, fill=outline, width=width)
+        draw.arc([x2 - 2*radius, y1, x2, y1 + 2*radius], 270, 360, fill=outline, width=width)
+        draw.arc([x1, y2 - 2*radius, x1 + 2*radius, y2], 90, 180, fill=outline, width=width)
+        draw.arc([x2 - 2*radius, y2 - 2*radius, x2, y2], 0, 90, fill=outline, width=width)
+        draw.line([x1 + radius, y1, x2 - radius, y1], fill=outline, width=width)
+        draw.line([x1 + radius, y2, x2 - radius, y2], fill=outline, width=width)
+        draw.line([x1, y1 + radius, x1, y2 - radius], fill=outline, width=width)
+        draw.line([x2, y1 + radius, x2, y2 - radius], fill=outline, width=width)
+
+def draw_lock_icon(draw, x, y, size, color):
+    """Draw a simple lock icon."""
+    # Lock body
+    body_w = size * 0.7
+    body_h = size * 0.5
+    body_x = x - body_w / 2
+    body_y = y + size * 0.1
+    draw_rounded_rect(draw, [body_x, body_y, body_x + body_w, body_y + body_h], 2, fill=color)
+
+    # Lock shackle (arc)
+    shackle_w = size * 0.45
+    shackle_h = size * 0.4
+    shackle_x = x - shackle_w / 2
+    shackle_y = y - size * 0.25
+    draw.arc([shackle_x, shackle_y, shackle_x + shackle_w, shackle_y + shackle_h],
+             180, 360, fill=color, width=2)
+
+def draw_computer_icon(draw, x, y, size, color):
+    """Draw a simple computer/monitor icon."""
+    # Monitor
+    mon_w = size * 0.8
+    mon_h = size * 0.55
+    mon_x = x - mon_w / 2
+    mon_y = y - size * 0.35
+    draw_rounded_rect(draw, [mon_x, mon_y, mon_x + mon_w, mon_y + mon_h], 3, outline=color, width=2)
+
+    # Screen inner
+    inner_margin = 4
+    draw_rounded_rect(draw, [mon_x + inner_margin, mon_y + inner_margin,
+                             mon_x + mon_w - inner_margin, mon_y + mon_h - inner_margin],
+                      2, fill=color)
+
+    # Stand
+    stand_w = size * 0.2
+    stand_h = size * 0.15
+    draw.rectangle([x - stand_w/2, mon_y + mon_h, x + stand_w/2, mon_y + mon_h + stand_h], fill=color)
+
+    # Base
+    base_w = size * 0.4
+    draw.rectangle([x - base_w/2, mon_y + mon_h + stand_h, x + base_w/2, mon_y + mon_h + stand_h + 3], fill=color)
+
+def draw_server_icon(draw, x, y, size, color):
+    """Draw a simple server icon."""
+    unit_h = size * 0.25
+    gap = 4
+    w = size * 0.75
+
+    for i in range(3):
+        uy = y - size * 0.4 + i * (unit_h + gap)
+        draw_rounded_rect(draw, [x - w/2, uy, x + w/2, uy + unit_h], 3, outline=color, width=2)
+        # LED dots
+        draw.ellipse([x + w/2 - 12, uy + unit_h/2 - 2, x + w/2 - 8, uy + unit_h/2 + 2], fill=color)
+
+def draw_printer_icon(draw, x, y, size, color):
+    """Draw a Bambu Lab style 3D printer icon."""
+    # Main body (cube-like)
+    body_w = size * 0.75
+    body_h = size * 0.7
+    body_x = x - body_w / 2
+    body_y = y - size * 0.35
+
+    # Outer frame with thicker border
+    draw_rounded_rect(draw, [body_x, body_y, body_x + body_w, body_y + body_h], 6, outline=color, width=2)
+
+    # Inner window/chamber
+    win_margin = 8
+    draw_rounded_rect(draw, [body_x + win_margin, body_y + win_margin,
+                             body_x + body_w - win_margin, body_y + body_h - 16],
+                      4, outline=color, width=1)
+
+    # Print bed line
+    bed_y = body_y + body_h - 12
+    draw.line([body_x + 12, bed_y, body_x + body_w - 12, bed_y], fill=color, width=2)
+
+    # Extruder/toolhead
+    ext_w = 16
+    ext_h = 8
+    ext_y = body_y + 18
+    draw_rounded_rect(draw, [x - ext_w/2, ext_y, x + ext_w/2, ext_y + ext_h], 2, fill=color)
+
+    # Small printed object on bed
+    obj_w = 12
+    obj_h = 10
+    draw_rounded_rect(draw, [x - obj_w/2, bed_y - obj_h, x + obj_w/2, bed_y], 2, fill=color)
+
+def draw_cloud_icon(draw, x, y, size, color):
+    """Draw a simple cloud icon."""
+    # Main cloud body using overlapping circles
+    r1 = size * 0.25
+    r2 = size * 0.2
+    r3 = size * 0.18
+
+    # Center circle
+    draw.ellipse([x - r1, y - r1 * 0.8, x + r1, y + r1 * 0.8], fill=color)
+    # Left circle
+    draw.ellipse([x - r1 - r2 * 0.7, y - r2 * 0.3, x - r1 + r2 * 0.7, y + r2 * 1.1], fill=color)
+    # Right circle
+    draw.ellipse([x + r1 * 0.3 - r2 * 0.5, y - r2 * 0.4, x + r1 * 0.3 + r2 * 1.2, y + r2 * 1.0], fill=color)
+    # Top circle
+    draw.ellipse([x - r3 * 0.5, y - r1 - r3 * 0.3, x + r3 * 1.2, y - r1 + r3 * 0.9], fill=color)
+
+def draw_arrow(draw, x1, y1, x2, y2, color, width=2):
+    """Draw a line with arrow head."""
+    draw.line([x1, y1, x2, y2], fill=color, width=width)
+
+    # Arrow head
+    import math
+    angle = math.atan2(y2 - y1, x2 - x1)
+    arrow_len = 10
+    arrow_angle = math.pi / 6
+
+    ax1 = x2 - arrow_len * math.cos(angle - arrow_angle)
+    ay1 = y2 - arrow_len * math.sin(angle - arrow_angle)
+    ax2 = x2 - arrow_len * math.cos(angle + arrow_angle)
+    ay2 = y2 - arrow_len * math.sin(angle + arrow_angle)
+
+    draw.polygon([(x2, y2), (ax1, ay1), (ax2, ay2)], fill=color)
+
+def draw_bidirectional_arrow(draw, x1, y1, x2, y2, color, width=2):
+    """Draw a bidirectional arrow."""
+    import math
+
+    # Shorten line slightly to make room for arrowheads
+    angle = math.atan2(y2 - y1, x2 - x1)
+    offset = 8
+
+    lx1 = x1 + offset * math.cos(angle)
+    ly1 = y1 + offset * math.sin(angle)
+    lx2 = x2 - offset * math.cos(angle)
+    ly2 = y2 - offset * math.sin(angle)
+
+    draw.line([lx1, ly1, lx2, ly2], fill=color, width=width)
+
+    # Arrow heads
+    arrow_len = 8
+    arrow_angle = math.pi / 6
+
+    # Right arrow
+    ax1 = x2 - arrow_len * math.cos(angle - arrow_angle)
+    ay1 = y2 - arrow_len * math.sin(angle - arrow_angle)
+    ax2 = x2 - arrow_len * math.cos(angle + arrow_angle)
+    ay2 = y2 - arrow_len * math.sin(angle + arrow_angle)
+    draw.polygon([(x2, y2), (ax1, ay1), (ax2, ay2)], fill=color)
+
+    # Left arrow
+    ax1 = x1 + arrow_len * math.cos(angle - arrow_angle)
+    ay1 = y1 + arrow_len * math.sin(angle - arrow_angle)
+    ax2 = x1 + arrow_len * math.cos(angle + arrow_angle)
+    ay2 = y1 + arrow_len * math.sin(angle + arrow_angle)
+    draw.polygon([(x1, y1), (ax1, ay1), (ax2, ay2)], fill=color)
+
+def draw_tls_badge(draw, x, y, fonts, color=TLS_BADGE_BG, text_color=BAMBU_GREEN):
+    """Draw a TLS badge."""
+    badge_w = 42
+    badge_h = 18
+    draw_rounded_rect(draw, [x - badge_w/2, y - badge_h/2, x + badge_w/2, y + badge_h/2],
+                      4, fill=color, outline=BAMBU_GREEN_DIM, width=1)
+
+    # Lock icon
+    draw_lock_icon(draw, x - 12, y - 2, 10, text_color)
+
+    # TLS text
+    draw.text((x + 2, y), "TLS", font=fonts['tls'], fill=text_color, anchor="lm")
+
+def create_diagram():
+    """Create the main diagram."""
+    img = Image.new('RGB', (WIDTH, HEIGHT), BG_COLOR)
+    draw = ImageDraw.Draw(img)
+    fonts = load_fonts()
+
+    # Title
+    title = "VIRTUAL PRINTER PROXY MODE"
+    draw.text((WIDTH // 2, 35), title, font=fonts['title'], fill=BAMBU_GREEN, anchor="mm")
+
+    # Subtitle
+    subtitle = "Secure remote printing through Bambuddy"
+    draw.text((WIDTH // 2, 62), subtitle, font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+
+    # === LAYOUT ===
+    # Three main sections: Remote | Internet | Local
+
+    section_y = 320
+
+    # Remote section (left)
+    remote_x = 180
+    remote_box = [40, 120, 320, 520]
+
+    # Internet section (center)
+    internet_x = 510
+
+    # Bambuddy section (center-right)
+    bambuddy_x = 700
+    bambuddy_box = [560, 140, 840, 500]
+
+    # Local section (right)
+    local_x = 1050
+    printer_x = 1220
+    local_box = [920, 120, 1360, 520]
+
+    # === REMOTE NETWORK ZONE ===
+    draw_rounded_rect(draw, remote_box, 12, fill=CONTAINER_BG, outline=CONTAINER_BORDER, width=1)
+    draw.text((180, 140), "REMOTE NETWORK", font=fonts['label'], fill=TEXT_LABEL, anchor="mm")
+
+    # Slicer icon and label
+    draw_computer_icon(draw, remote_x, section_y - 40, 70, BAMBU_GREEN)
+    draw.text((remote_x, section_y + 30), "Bambu Studio", font=fonts['heading'], fill=TEXT_PRIMARY, anchor="mm")
+    draw.text((remote_x, section_y + 52), "or OrcaSlicer", font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+
+    # Ports on remote side
+    draw.text((remote_x, section_y + 100), "Connects to Bambuddy", font=fonts['small'], fill=TEXT_LABEL, anchor="mm")
+    draw.text((remote_x, section_y + 120), "FTP :9990  MQTT :8883", font=fonts['port_small'], fill=TEXT_SECONDARY, anchor="mm")
+
+    # === INTERNET CLOUD ===
+    draw_cloud_icon(draw, internet_x, section_y, 80, INTERNET_COLOR)
+    draw.text((internet_x, section_y + 55), "Internet", font=fonts['label'], fill=TEXT_LABEL, anchor="mm")
+
+    # === BAMBUDDY SERVER ===
+    draw_rounded_rect(draw, bambuddy_box, 12, fill=CONTAINER_BG, outline=BAMBU_GREEN_DIM, width=2)
+    draw.text((bambuddy_x, 165), "BAMBUDDY SERVER", font=fonts['label'], fill=BAMBU_GREEN, anchor="mm")
+
+    # Server icon
+    draw_server_icon(draw, bambuddy_x, section_y - 50, 70, BAMBU_GREEN)
+    draw.text((bambuddy_x, section_y + 20), "TLS Proxy", font=fonts['heading'], fill=TEXT_PRIMARY, anchor="mm")
+
+    # Incoming ports (left side of Bambuddy)
+    draw.text((bambuddy_x, section_y + 70), "LISTEN PORTS", font=fonts['small'], fill=TEXT_LABEL, anchor="mm")
+    draw_rounded_rect(draw, [bambuddy_x - 55, section_y + 85, bambuddy_x + 55, section_y + 130],
+                      6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)
+    draw.text((bambuddy_x, section_y + 98), "FTP", font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+    draw.text((bambuddy_x, section_y + 115), "9990", font=fonts['port'], fill=BAMBU_GREEN, anchor="mm")
+
+    draw_rounded_rect(draw, [bambuddy_x - 55, section_y + 140, bambuddy_x + 55, section_y + 185],
+                      6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)
+    draw.text((bambuddy_x, section_y + 153), "MQTT", font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+    draw.text((bambuddy_x, section_y + 170), "8883", font=fonts['port'], fill=BAMBU_GREEN, anchor="mm")
+
+    # === LOCAL NETWORK ZONE ===
+    draw_rounded_rect(draw, local_box, 12, fill=CONTAINER_BG, outline=CONTAINER_BORDER, width=1)
+    draw.text((1140, 140), "LOCAL NETWORK", font=fonts['label'], fill=TEXT_LABEL, anchor="mm")
+
+    # "LAN Mode" badge
+    draw_rounded_rect(draw, [1100, 155, 1180, 175], 4, fill=TLS_BADGE_BG, outline=BAMBU_GREEN_DIM, width=1)
+    draw.text((1140, 165), "LAN Mode", font=fonts['tls'], fill=BAMBU_GREEN, anchor="mm")
+
+    # Printer icon
+    draw_printer_icon(draw, printer_x, section_y - 40, 80, BAMBU_GREEN)
+    draw.text((printer_x, section_y + 35), "Bambu Lab", font=fonts['heading'], fill=TEXT_PRIMARY, anchor="mm")
+    draw.text((printer_x, section_y + 55), "Printer", font=fonts['heading'], fill=TEXT_PRIMARY, anchor="mm")
+
+    # Target ports
+    draw.text((printer_x, section_y + 100), "Printer Ports", font=fonts['small'], fill=TEXT_LABEL, anchor="mm")
+    draw.text((printer_x, section_y + 120), "FTP :990  MQTT :8883", font=fonts['port_small'], fill=TEXT_SECONDARY, anchor="mm")
+
+    # Proxy target label
+    draw_rounded_rect(draw, [local_x - 60, section_y - 80, local_x + 60, section_y - 50],
+                      6, fill=(35, 35, 45), outline=CONTAINER_BORDER, width=1)
+    draw.text((local_x, section_y - 65), "Target IP", font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+
+    # === CONNECTION ARROWS ===
+
+    # Remote to Internet
+    draw_bidirectional_arrow(draw, 325, section_y, 460, section_y, BAMBU_GREEN_DIM, 2)
+
+    # TLS badge between remote and internet
+    draw_tls_badge(draw, 392, section_y - 20, fonts)
+
+    # Internet to Bambuddy
+    draw_bidirectional_arrow(draw, 555, section_y, 620, section_y, BAMBU_GREEN_DIM, 2)
+
+    # Bambuddy to Local
+    draw_bidirectional_arrow(draw, 780, section_y, 920, section_y, BAMBU_GREEN_DIM, 2)
+
+    # TLS badge between Bambuddy and printer
+    draw_tls_badge(draw, 850, section_y - 20, fonts)
+
+    # Local network arrow to printer
+    draw_bidirectional_arrow(draw, 990, section_y, 1130, section_y, BAMBU_GREEN_DIM, 2)
+
+    # === BOTTOM INFO ===
+    info_y = 560
+
+    # Flow description
+    draw.text((WIDTH // 2, info_y), "← Slicer traffic encrypted and relayed through Bambuddy to your printer →",
+              font=fonts['small'], fill=TEXT_SECONDARY, anchor="mm")
+
+    # Key features
+    features_y = 600
+    features = [
+        "End-to-end TLS encryption",
+        "No cloud dependency",
+        "Uses printer's access code"
+    ]
+
+    spacing = 280
+    start_x = WIDTH // 2 - spacing
+
+    for i, feature in enumerate(features):
+        fx = start_x + i * spacing
+        # Bullet
+        draw.ellipse([fx - 80, features_y - 3, fx - 74, features_y + 3], fill=BAMBU_GREEN)
+        draw.text((fx - 68, features_y), feature, font=fonts['small'], fill=TEXT_SECONDARY, anchor="lm")
+
+    # Bambuddy branding
+    draw.text((WIDTH // 2, HEIGHT - 30), "bambuddy.cool", font=fonts['small'], fill=TEXT_LABEL, anchor="mm")
+
+    return img
+
+def main():
+    """Generate and save the diagram."""
+    img = create_diagram()
+
+    output_path = Path("/opt/claude/projects/bambuddy/docs/images/proxy-mode-diagram.png")
+    output_path.parent.mkdir(parents=True, exist_ok=True)
+
+    img.save(output_path, "PNG", dpi=(150, 150))
+    print(f"Diagram saved to: {output_path}")
+
+    # Also save to frontend docs
+    frontend_path = Path("/opt/claude/projects/bambuddy/frontend/docs/proxy-mode-diagram.png")
+    frontend_path.parent.mkdir(parents=True, exist_ok=True)
+    img.save(frontend_path, "PNG", dpi=(150, 150))
+    print(f"Also saved to: {frontend_path}")
+
+if __name__ == "__main__":
+    main()

BIN
frontend/docs/proxy-mode-diagram.png


+ 185 - 0
frontend/src/__tests__/api/client.test.ts

@@ -0,0 +1,185 @@
+/**
+ * Tests for the API client auth token handling.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { http, HttpResponse } from 'msw';
+import { setupServer } from 'msw/node';
+import { setAuthToken, getAuthToken, api } from '../../api/client';
+
+// Mock localStorage
+const localStorageMock = {
+  store: {} as Record<string, string>,
+  getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
+  setItem: vi.fn((key: string, value: string) => {
+    localStorageMock.store[key] = value;
+  }),
+  removeItem: vi.fn((key: string) => {
+    delete localStorageMock.store[key];
+  }),
+  clear: vi.fn(() => {
+    localStorageMock.store = {};
+  }),
+};
+
+Object.defineProperty(window, 'localStorage', {
+  value: localStorageMock,
+});
+
+// Create MSW server
+const server = setupServer();
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
+afterEach(() => {
+  server.resetHandlers();
+  localStorageMock.clear();
+  setAuthToken(null);
+});
+afterAll(() => server.close());
+
+describe('Auth Token Management', () => {
+  it('setAuthToken stores token in localStorage', () => {
+    setAuthToken('test-token-123');
+    expect(localStorageMock.setItem).toHaveBeenCalledWith('auth_token', 'test-token-123');
+    expect(getAuthToken()).toBe('test-token-123');
+  });
+
+  it('setAuthToken removes token from localStorage when null', () => {
+    setAuthToken('test-token-123');
+    setAuthToken(null);
+    expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
+    expect(getAuthToken()).toBeNull();
+  });
+});
+
+describe('API Client Auth Header', () => {
+  it('includes Authorization header when token is set', async () => {
+    let capturedHeaders: Headers | null = null;
+
+    server.use(
+      http.get('/api/v1/settings/spoolman', ({ request }) => {
+        capturedHeaders = request.headers;
+        return HttpResponse.json({
+          spoolman_enabled: 'false',
+          spoolman_url: '',
+          spoolman_sync_mode: 'auto',
+        });
+      })
+    );
+
+    setAuthToken('test-jwt-token');
+    await api.getSpoolmanSettings();
+
+    expect(capturedHeaders).not.toBeNull();
+    expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-jwt-token');
+  });
+
+  it('does not include Authorization header when token is not set', async () => {
+    let capturedHeaders: Headers | null = null;
+
+    server.use(
+      http.get('/api/v1/settings/spoolman', ({ request }) => {
+        capturedHeaders = request.headers;
+        return HttpResponse.json({
+          spoolman_enabled: 'false',
+          spoolman_url: '',
+          spoolman_sync_mode: 'auto',
+        });
+      })
+    );
+
+    setAuthToken(null);
+    await api.getSpoolmanSettings();
+
+    expect(capturedHeaders).not.toBeNull();
+    expect(capturedHeaders!.get('Authorization')).toBeNull();
+  });
+
+  it('clears token on 401 Unauthorized response', async () => {
+    server.use(
+      http.get('/api/v1/settings/spoolman', () => {
+        return HttpResponse.json(
+          { detail: 'Not authenticated' },
+          { status: 401 }
+        );
+      })
+    );
+
+    setAuthToken('expired-token');
+    expect(getAuthToken()).toBe('expired-token');
+
+    try {
+      await api.getSpoolmanSettings();
+    } catch {
+      // Expected to throw
+    }
+
+    expect(getAuthToken()).toBeNull();
+    expect(localStorageMock.removeItem).toHaveBeenCalledWith('auth_token');
+  });
+});
+
+describe('FormData requests include auth header', () => {
+  it('importProjectFile includes Authorization header', async () => {
+    // Mock fetch directly for FormData requests (MSW can be flaky with multipart in some environments)
+    const originalFetch = global.fetch;
+    let capturedHeaders: Headers | null = null;
+
+    global.fetch = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
+      if (url.includes('/projects/import/file')) {
+        capturedHeaders = new Headers(init?.headers);
+        return Promise.resolve(new Response(JSON.stringify({
+          id: 1,
+          name: 'Test Project',
+          description: '',
+          total_cost: 0,
+          total_print_time_seconds: 0,
+          total_prints: 0,
+          total_quantity: 0,
+          status: 'active',
+          due_date: null,
+          created_at: '2026-01-01T00:00:00Z',
+          updated_at: '2026-01-01T00:00:00Z',
+          archives: [],
+          bom_items: [],
+        }), { status: 200 }));
+      }
+      return originalFetch(url, init);
+    });
+
+    try {
+      setAuthToken('test-token');
+      const file = new File(['test content'], 'test.zip', { type: 'application/zip' });
+      await api.importProjectFile(file);
+
+      expect(capturedHeaders).not.toBeNull();
+      expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
+    } finally {
+      global.fetch = originalFetch;
+    }
+  });
+
+  it('exportProjectZip includes Authorization header', async () => {
+    let capturedHeaders: Headers | null = null;
+
+    server.use(
+      http.get('/api/v1/projects/:projectId/export', ({ request }) => {
+        capturedHeaders = request.headers;
+        const zipContent = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic bytes
+        return new HttpResponse(zipContent, {
+          status: 200,
+          headers: {
+            'Content-Type': 'application/zip',
+            'Content-Disposition': 'attachment; filename="project.zip"',
+          },
+        });
+      })
+    );
+
+    setAuthToken('test-token');
+    await api.exportProjectZip(1);
+
+    expect(capturedHeaders).not.toBeNull();
+    expect(capturedHeaders!.get('Authorization')).toBe('Bearer test-token');
+  });
+});

+ 1 - 1
frontend/src/__tests__/components/ConfirmModal.test.tsx

@@ -131,7 +131,7 @@ describe('ConfirmModal', () => {
 
     it('shows default loading text when loadingText not provided', () => {
       render(<ConfirmModal {...defaultProps} isLoading={true} />);
-      expect(screen.getByText('Processing...')).toBeInTheDocument();
+      expect(screen.getByText('Loading...')).toBeInTheDocument();
     });
 
     it('disables buttons when loading', () => {

+ 291 - 0
frontend/src/__tests__/components/LinkSpoolModal.test.tsx

@@ -0,0 +1,291 @@
+/**
+ * Tests for the LinkSpoolModal component.
+ *
+ * Tests the Spoolman link spool modal including:
+ * - Displaying unlinked spools
+ * - Selecting a spool to link
+ * - Link success with toast notification
+ * - Link error with toast notification
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { LinkSpoolModal } from '../../components/LinkSpoolModal';
+
+// Mock the API client
+vi.mock('../../api/client', () => ({
+  api: {
+    getUnlinkedSpools: vi.fn(),
+    linkSpool: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ enabled: false, configured: false }),
+  },
+}));
+
+// Mock the toast context
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+// Import mocked module
+import { api } from '../../api/client';
+
+describe('LinkSpoolModal', () => {
+  const defaultProps = {
+    isOpen: true,
+    onClose: vi.fn(),
+    trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
+    trayInfo: {
+      type: 'PLA Basic',
+      color: 'FF0000',
+      location: 'AMS A1',
+    },
+  };
+
+  const mockUnlinkedSpools = [
+    {
+      id: 1,
+      filament_name: 'PLA Red',
+      filament_material: 'PLA',
+      filament_color_hex: 'FF0000',
+      remaining_weight: 800,
+      location: 'Shelf A',
+    },
+    {
+      id: 2,
+      filament_name: 'PETG Blue',
+      filament_material: 'PETG',
+      filament_color_hex: '0000FF',
+      remaining_weight: 500,
+      location: null,
+    },
+  ];
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockUnlinkedSpools);
+    vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'Linked' });
+  });
+
+  describe('rendering', () => {
+    it('renders modal title', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        // Look for the title in h2 element
+        expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
+      });
+    });
+
+    it('displays tray info', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Basic')).toBeInTheDocument();
+        expect(screen.getByText('(AMS A1)')).toBeInTheDocument();
+      });
+    });
+
+    it('displays tray UUID', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText(defaultProps.trayUuid)).toBeInTheDocument();
+      });
+    });
+
+    it('shows loading state while fetching spools', async () => {
+      // Delay the response
+      vi.mocked(api.getUnlinkedSpools).mockImplementation(
+        () => new Promise(() => {})
+      );
+
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+      });
+    });
+
+    it('displays unlinked spools list', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+        expect(screen.getByText('PETG Blue')).toBeInTheDocument();
+      });
+    });
+
+    it('shows message when no unlinked spools', async () => {
+      vi.mocked(api.getUnlinkedSpools).mockResolvedValue([]);
+
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('No unlinked spools available')).toBeInTheDocument();
+      });
+    });
+
+    it('does not render when isOpen is false', () => {
+      render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
+      expect(screen.queryByRole('heading', { name: /link to spoolman/i })).not.toBeInTheDocument();
+    });
+  });
+
+  describe('spool selection', () => {
+    it('allows selecting a spool', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      // Click to select spool
+      fireEvent.click(screen.getByText('PLA Red'));
+
+      // Should show check mark (via visual styling)
+      const selectedButton = screen.getByText('PLA Red').closest('button');
+      expect(selectedButton).toHaveClass('border-bambu-green');
+    });
+
+    it('link button is disabled until spool is selected', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      const linkButton = screen.getByRole('button', { name: /link to spoolman/i });
+      expect(linkButton).toBeDisabled();
+
+      // Select a spool
+      fireEvent.click(screen.getByText('PLA Red'));
+
+      expect(linkButton).not.toBeDisabled();
+    });
+  });
+
+  describe('linking', () => {
+    it('calls linkSpool API on submit', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      // Select a spool
+      fireEvent.click(screen.getByText('PLA Red'));
+
+      // Click link button
+      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
+
+      await waitFor(() => {
+        expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid);
+      });
+    });
+
+    it('shows success toast on successful link', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByText('PLA Red'));
+      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
+
+      await waitFor(() => {
+        expect(mockShowToast).toHaveBeenCalledWith(
+          'Spool linked to Spoolman successfully',
+          'success'
+        );
+      });
+    });
+
+    it('calls onClose after successful link', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByText('PLA Red'));
+      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
+
+      await waitFor(() => {
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      });
+    });
+
+    it('shows error toast on link failure', async () => {
+      const errorMessage = 'Failed to update spool';
+      vi.mocked(api.linkSpool).mockRejectedValue(new Error(errorMessage));
+
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByText('PLA Red'));
+      fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i }));
+
+      await waitFor(() => {
+        expect(mockShowToast).toHaveBeenCalledWith(
+          `Failed to link spool: ${errorMessage}`,
+          'error'
+        );
+      });
+    });
+  });
+
+  describe('modal actions', () => {
+    it('calls onClose when cancel button is clicked', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Cancel')).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByText('Cancel'));
+      expect(defaultProps.onClose).toHaveBeenCalled();
+    });
+
+    it('calls onClose when backdrop is clicked', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
+      });
+
+      // Click the backdrop (the element with bg-black/60)
+      const backdrop = document.querySelector('.bg-black\\/60');
+      if (backdrop) {
+        fireEvent.click(backdrop);
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      }
+    });
+
+    it('calls onClose when X button is clicked', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument();
+      });
+
+      // Find and click the X button in the header
+      const closeButtons = screen.getAllByRole('button');
+      const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
+      if (xButton) {
+        fireEvent.click(xButton);
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      }
+    });
+  });
+});

+ 24 - 43
frontend/src/__tests__/components/SpoolmanSettings.test.tsx

@@ -18,6 +18,8 @@ vi.mock('../../api/client', () => ({
   api: {
     getSettings: vi.fn().mockResolvedValue({}),
     updateSettings: vi.fn().mockResolvedValue({}),
+    getSpoolmanSettings: vi.fn(),
+    updateSpoolmanSettings: vi.fn(),
     getSpoolmanStatus: vi.fn(),
     connectSpoolman: vi.fn(),
     disconnectSpoolman: vi.fn(),
@@ -30,17 +32,21 @@ vi.mock('../../api/client', () => ({
 // Import mocked module
 import { api } from '../../api/client';
 
-// Mock fetch for Spoolman settings endpoints
-const mockFetchResponse = (data: object) => ({
-  ok: true,
-  json: () => Promise.resolve(data),
-});
-
 describe('SpoolmanSettings', () => {
   beforeEach(() => {
     vi.clearAllMocks();
 
     // Default API mocks
+    vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
+      spoolman_enabled: 'false',
+      spoolman_url: '',
+      spoolman_sync_mode: 'auto',
+    });
+    vi.mocked(api.updateSpoolmanSettings).mockResolvedValue({
+      spoolman_enabled: 'false',
+      spoolman_url: '',
+      spoolman_sync_mode: 'auto',
+    });
     vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
       enabled: false,
       connected: false,
@@ -56,26 +62,12 @@ describe('SpoolmanSettings', () => {
       skipped: [],
       errors: [],
     });
-
-    // Default fetch mock for settings - disabled state
-    global.fetch = vi.fn().mockImplementation((url: string) => {
-      if (url.includes('/api/v1/settings/spoolman')) {
-        return Promise.resolve(
-          mockFetchResponse({
-            spoolman_enabled: 'false',
-            spoolman_url: '',
-            spoolman_sync_mode: 'auto',
-          })
-        );
-      }
-      return Promise.reject(new Error('Unknown URL'));
-    }) as any;
   });
 
   describe('rendering', () => {
     it('renders loading state initially', () => {
-      // Delay the fetch response to catch loading state
-      global.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) as any;
+      // Delay the API response to catch loading state
+      vi.mocked(api.getSpoolmanSettings).mockImplementation(() => new Promise(() => {}));
       render(<SpoolmanSettings />);
 
       // Should show loading spinner
@@ -159,27 +151,16 @@ describe('SpoolmanSettings', () => {
 
   describe('enabled state', () => {
     beforeEach(() => {
-      global.fetch = vi.fn().mockImplementation((url: string) => {
-        if (url.includes('/api/v1/settings/spoolman')) {
-          if (url.includes('PUT') || (url as any).method === 'PUT') {
-            return Promise.resolve(
-              mockFetchResponse({
-                spoolman_enabled: 'true',
-                spoolman_url: 'http://localhost:7912',
-                spoolman_sync_mode: 'auto',
-              })
-            );
-          }
-          return Promise.resolve(
-            mockFetchResponse({
-              spoolman_enabled: 'true',
-              spoolman_url: 'http://localhost:7912',
-              spoolman_sync_mode: 'auto',
-            })
-          );
-        }
-        return Promise.reject(new Error('Unknown URL'));
-      }) as any;
+      vi.mocked(api.getSpoolmanSettings).mockResolvedValue({
+        spoolman_enabled: 'true',
+        spoolman_url: 'http://localhost:7912',
+        spoolman_sync_mode: 'auto',
+      });
+      vi.mocked(api.updateSpoolmanSettings).mockResolvedValue({
+        spoolman_enabled: 'true',
+        spoolman_url: 'http://localhost:7912',
+        spoolman_sync_mode: 'auto',
+      });
     });
 
     it('URL input is enabled when Spoolman is enabled', async () => {

+ 99 - 2
frontend/src/__tests__/components/VirtualPrinterSettings.test.tsx

@@ -42,6 +42,7 @@ const createMockSettings = (overrides = {}) => ({
   access_code_set: false,
   mode: 'immediate' as const,
   model: '3DPrinter-X1-Carbon',
+  target_printer_id: null as number | null,
   status: {
     enabled: false,
     running: false,
@@ -274,7 +275,7 @@ describe('VirtualPrinterSettings', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Review')).toBeInTheDocument();
-        expect(screen.getByText('Review and tag before archiving')).toBeInTheDocument();
+        expect(screen.getByText('Review before archiving')).toBeInTheDocument();
       });
     });
 
@@ -283,7 +284,7 @@ describe('VirtualPrinterSettings', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Queue')).toBeInTheDocument();
-        expect(screen.getByText('Archive and add to print queue')).toBeInTheDocument();
+        expect(screen.getByText('Archive and add to queue')).toBeInTheDocument();
       });
     });
 
@@ -472,4 +473,100 @@ describe('VirtualPrinterSettings', () => {
       });
     });
   });
+
+  describe('proxy mode', () => {
+    it('renders Proxy mode option', async () => {
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Proxy')).toBeInTheDocument();
+        expect(screen.getByText('Relay to real printer')).toBeInTheDocument();
+      });
+    });
+
+    it('highlights proxy mode when selected', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ mode: 'proxy', target_printer_id: 1 })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        const proxyButton = screen.getByText('Proxy').closest('button');
+        expect(proxyButton?.className).toContain('border-blue-500');
+      });
+    });
+
+    it('shows proxy status details when running in proxy mode', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({
+          enabled: true,
+          mode: 'proxy',
+          target_printer_id: 1,
+          status: {
+            enabled: true,
+            running: true,
+            mode: 'proxy',
+            name: 'Bambuddy (Proxy)',
+            serial: '00M00A391800001',
+            model: '3DPrinter-X1-Carbon',
+            model_name: 'X1C',
+            pending_files: 0,
+            proxy: {
+              running: true,
+              target_host: '192.168.1.100',
+              ftp_port: 9990,
+              mqtt_port: 8883,
+              ftp_connections: 1,
+              mqtt_connections: 2,
+            },
+          },
+        })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Running')).toBeInTheDocument();
+        expect(screen.getByText('Status Details')).toBeInTheDocument();
+        // IP address appears in multiple places (target printer and status details)
+        expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0);
+      });
+    });
+
+    it('shows target printer dropdown in proxy mode', async () => {
+      vi.mocked(virtualPrinterApi.getSettings).mockResolvedValue(
+        createMockSettings({ mode: 'proxy' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Target Printer')).toBeInTheDocument();
+        expect(screen.getByText('Select a printer...')).toBeInTheDocument();
+      });
+    });
+
+    it('changes mode to proxy on click', async () => {
+      const user = userEvent.setup();
+      vi.mocked(virtualPrinterApi.updateSettings).mockResolvedValue(
+        createMockSettings({ mode: 'proxy' })
+      );
+
+      render(<VirtualPrinterSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Proxy')).toBeInTheDocument();
+      });
+
+      const proxyButton = screen.getByText('Proxy').closest('button');
+      if (proxyButton) {
+        await user.click(proxyButton);
+      }
+
+      await waitFor(() => {
+        expect(virtualPrinterApi.updateSettings).toHaveBeenCalledWith({ mode: 'proxy' });
+      });
+    });
+  });
 });

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

@@ -166,4 +166,99 @@ describe('AuthContext', () => {
       expect(result.current.hasPermission('printers:read' as Permission)).toBe(false);
     });
   });
+
+  describe('CVE-2026-25505 fix: auth disabled grants all access', () => {
+    beforeEach(() => {
+      server.use(
+        http.get('/api/v1/auth/status', () => {
+          return HttpResponse.json({
+            auth_enabled: false,
+            requires_setup: false,
+          });
+        })
+      );
+    });
+
+    it('isAdmin is true when auth is disabled', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.loading).toBe(false);
+      });
+
+      // When auth disabled, user is treated as admin
+      expect(result.current.isAdmin).toBe(true);
+    });
+
+    it('canModify allows all modifications when auth disabled', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.loading).toBe(false);
+      });
+
+      // All canModify checks should pass when auth is disabled
+      expect(result.current.canModify('queue', 'update', 1)).toBe(true);
+      expect(result.current.canModify('queue', 'update', 999)).toBe(true);
+      expect(result.current.canModify('queue', 'update', null)).toBe(true);
+      expect(result.current.canModify('archives', 'delete', 1)).toBe(true);
+      expect(result.current.canModify('library', 'update', null)).toBe(true);
+    });
+
+    it('all permissions are granted when auth is disabled', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.loading).toBe(false);
+      });
+
+      // All permission checks should pass
+      expect(result.current.hasPermission('archives:read' as Permission)).toBe(true);
+      expect(result.current.hasPermission('archives:delete_all' as Permission)).toBe(true);
+      expect(result.current.hasPermission('settings:update' as Permission)).toBe(true);
+      expect(result.current.hasPermission('api_keys:create' as Permission)).toBe(true);
+      expect(result.current.hasPermission('groups:delete' as Permission)).toBe(true);
+    });
+
+    it('hasAnyPermission returns true for protected permissions', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.loading).toBe(false);
+      });
+
+      expect(
+        result.current.hasAnyPermission(
+          'api_keys:create' as Permission,
+          'groups:delete' as Permission
+        )
+      ).toBe(true);
+    });
+
+    it('hasAllPermissions returns true for any combination', async () => {
+      const { result } = renderHook(() => useAuth(), {
+        wrapper: createWrapper(),
+      });
+
+      await waitFor(() => {
+        expect(result.current.loading).toBe(false);
+      });
+
+      expect(
+        result.current.hasAllPermissions(
+          'settings:update' as Permission,
+          'api_keys:create' as Permission,
+          'groups:delete' as Permission
+        )
+      ).toBe(true);
+    });
+  });
 });

+ 35 - 0
frontend/src/__tests__/i18n/locales.test.ts

@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest';
+import en from '../../i18n/locales/en';
+import de from '../../i18n/locales/de';
+
+/**
+ * Recursively extracts all keys from a nested object as dot-notation paths.
+ * Example: { foo: { bar: 'baz' } } => ['foo.bar']
+ */
+const getKeys = (obj: object, prefix = ''): string[] => {
+  return Object.entries(obj).flatMap(([key, value]) => {
+    const path = prefix ? `${prefix}.${key}` : key;
+    return typeof value === 'object' && value !== null
+      ? getKeys(value, path)
+      : [path];
+  });
+};
+
+describe('i18n locale parity', () => {
+  const enKeys = new Set(getKeys(en));
+  const deKeys = new Set(getKeys(de));
+
+  it('German locale has all English keys', () => {
+    const missingInGerman = [...enKeys].filter((k) => !deKeys.has(k)).sort();
+    expect(missingInGerman, `Missing ${missingInGerman.length} key(s) in German locale`).toEqual([]);
+  });
+
+  it('English locale has all German keys', () => {
+    const missingInEnglish = [...deKeys].filter((k) => !enKeys.has(k)).sort();
+    expect(missingInEnglish, `Missing ${missingInEnglish.length} key(s) in English locale`).toEqual([]);
+  });
+
+  it('both locales have the same number of keys', () => {
+    expect(enKeys.size).toBe(deKeys.size);
+  });
+});

+ 18 - 12
frontend/src/__tests__/pages/CameraPage.test.tsx

@@ -11,6 +11,8 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { ThemeProvider } from '../../contexts/ThemeContext';
 import { ToastProvider } from '../../contexts/ToastContext';
+import { I18nextProvider } from 'react-i18next';
+import i18n from '../../i18n';
 
 // Mock navigator.sendBeacon which isn't available in jsdom
 vi.stubGlobal('navigator', {
@@ -39,15 +41,17 @@ function renderCameraPage(printerId: number) {
 
   return rtlRender(
     <QueryClientProvider client={queryClient}>
-      <MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
-        <ThemeProvider>
-          <ToastProvider>
-            <Routes>
-              <Route path="/cameras/:printerId" element={<CameraPage />} />
-            </Routes>
-          </ToastProvider>
-        </ThemeProvider>
-      </MemoryRouter>
+      <I18nextProvider i18n={i18n}>
+        <MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
+          <ThemeProvider>
+            <ToastProvider>
+              <Routes>
+                <Route path="/cameras/:printerId" element={<CameraPage />} />
+              </Routes>
+            </ToastProvider>
+          </ThemeProvider>
+        </MemoryRouter>
+      </I18nextProvider>
     </QueryClientProvider>
   );
 }
@@ -94,8 +98,9 @@ describe('CameraPage', () => {
       renderCameraPage(1);
 
       await waitFor(() => {
-        expect(screen.getByText('Live')).toBeInTheDocument();
-        expect(screen.getByText('Snapshot')).toBeInTheDocument();
+        // Check for translation key or translated text
+        expect(screen.getByText(/Live|camera\.live/)).toBeInTheDocument();
+        expect(screen.getByText(/Snapshot|camera\.snapshot/)).toBeInTheDocument();
       });
     });
 
@@ -124,7 +129,8 @@ describe('CameraPage', () => {
       renderCameraPage(0);
 
       await waitFor(() => {
-        expect(screen.getByText('Invalid printer ID')).toBeInTheDocument();
+        // Check for translation key or translated text
+        expect(screen.getByText(/Invalid printer ID|camera\.invalidPrinterId/)).toBeInTheDocument();
       });
     });
   });

+ 220 - 10
frontend/src/api/client.ts

@@ -771,6 +771,8 @@ export interface CloudLoginResponse {
   success: boolean;
   needs_verification: boolean;
   message: string;
+  verification_type?: 'email' | 'totp' | null;
+  tfa_key?: string | null;
 }
 
 export interface SlicerSetting {
@@ -1069,6 +1071,7 @@ export interface PrintQueueItem {
   id: number;
   printer_id: number | null;  // null = unassigned
   target_model: string | null;  // Target printer model for model-based assignment
+  target_location: string | null;  // Target location filter for model-based assignment
   required_filament_types: string[] | null;  // Required filament types for model-based assignment
   waiting_reason: string | null;  // Why a model-based job hasn't started yet
   // Either archive_id OR library_file_id must be set (archive created at print start)
@@ -1107,6 +1110,7 @@ export interface PrintQueueItem {
 export interface PrintQueueItemCreate {
   printer_id?: number | null;  // null = unassigned
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
+  target_location?: string | null;  // Target location filter (only used with target_model)
   // Either archive_id OR library_file_id must be provided
   archive_id?: number | null;
   library_file_id?: number | null;
@@ -1128,6 +1132,7 @@ export interface PrintQueueItemCreate {
 export interface PrintQueueItemUpdate {
   printer_id?: number | null;  // null = unassign
   target_model?: string | null;  // Target printer model (mutually exclusive with printer_id)
+  target_location?: string | null;  // Target location filter (only used with target_model)
   position?: number;
   scheduled_time?: string | null;
   require_previous_success?: boolean;
@@ -1603,6 +1608,10 @@ export interface UnlinkedSpool {
   location: string | null;
 }
 
+export interface LinkedSpoolsMap {
+  linked: Record<string, number>; // tag (uppercase) -> spool_id
+}
+
 // Update types
 export interface VersionInfo {
   version: string;
@@ -2083,10 +2092,40 @@ export const api = {
     }>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
   getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
     `${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`,
+  downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(
+      `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
+      { headers }
+    );
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
+    const filename = filenameMatch?.[1] || path.split('/').pop() || 'download';
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
   downloadPrinterFilesAsZip: async (printerId: number, paths: string[]): Promise<Blob> => {
+    const headers: Record<string, string> = { 'Content-Type': 'application/json' };
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
     const response = await fetch(`${API_BASE}/printers/${printerId}/files/download-zip`, {
       method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
+      headers,
       body: JSON.stringify({ paths }),
     });
     if (!response.ok) {
@@ -2195,7 +2234,11 @@ export const api = {
     if (options?.dateTo) params.set('date_to', options.dateTo);
     if (options?.search) params.set('search', options.search);
 
-    const response = await fetch(`${API_BASE}/archives/export?${params}`);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/archives/export?${params}`, { headers });
     if (!response.ok) {
       const error = await response.json().catch(() => ({}));
       throw new Error(error.detail || `HTTP ${response.status}`);
@@ -2223,7 +2266,11 @@ export const api = {
     if (options?.printerId) params.set('printer_id', String(options.printerId));
     if (options?.projectId) params.set('project_id', String(options.projectId));
 
-    const response = await fetch(`${API_BASE}/archives/stats/export?${params}`);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/archives/stats/export?${params}`, { headers });
     if (!response.ok) {
       const error = await response.json().catch(() => ({}));
       throw new Error(error.detail || `HTTP ${response.status}`);
@@ -2249,6 +2296,29 @@ export const api = {
   getArchivePlateThumbnail: (id: number, plateIndex: number) =>
     `${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`,
   getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
+  downloadArchive: async (id: number, filename?: string): Promise<void> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/archives/${id}/download`, { headers });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
+    const downloadFilename = filenameMatch?.[1] || filename || `archive_${id}.3mf`;
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = downloadFilename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
   getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
   getArchivePlatePreview: (id: number) => `${API_BASE}/archives/${id}/plate-preview`,
   getArchiveTimelapse: (id: number) => `${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`,
@@ -2367,6 +2437,29 @@ export const api = {
   // Source 3MF (original slicer project file)
   getSource3mfDownloadUrl: (archiveId: number) =>
     `${API_BASE}/archives/${archiveId}/source`,
+  downloadSource3mf: async (archiveId: number): Promise<void> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, { headers });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
+    const filename = filenameMatch?.[1] || `source_${archiveId}.3mf`;
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
   getSource3mfForSlicer: (archiveId: number, filename: string) =>
     `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
@@ -2394,6 +2487,29 @@ export const api = {
   // F3D (Fusion 360 design file)
   getF3dDownloadUrl: (archiveId: number) =>
     `${API_BASE}/archives/${archiveId}/f3d`,
+  downloadF3d: async (archiveId: number): Promise<void> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, { headers });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
+    const filename = filenameMatch?.[1] || `archive_${archiveId}.f3d`;
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
   uploadF3d: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     formData.append('file', file);
@@ -2562,7 +2678,11 @@ export const api = {
   exportBackup: async (): Promise<{ blob: Blob; filename: string }> => {
     // New simplified backup - complete database + all files
     const url = `${API_BASE}/settings/backup`;
-    const response = await fetch(url);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(url, { headers });
 
     // Check for errors
     if (!response.ok) {
@@ -2610,10 +2730,10 @@ export const api = {
       method: 'POST',
       body: JSON.stringify({ email, password, region }),
     }),
-  cloudVerify: (email: string, code: string) =>
+  cloudVerify: (email: string, code: string, tfaKey?: string) =>
     request<CloudLoginResponse>('/cloud/verify', {
       method: 'POST',
-      body: JSON.stringify({ email, code }),
+      body: JSON.stringify({ email, code, tfa_key: tfaKey }),
     }),
   cloudSetToken: (access_token: string) =>
     request<CloudAuthStatus>('/cloud/token', {
@@ -2950,11 +3070,20 @@ export const api = {
     request<{ filaments: unknown[] }>('/spoolman/filaments'),
   getUnlinkedSpools: () =>
     request<UnlinkedSpool[]>('/spoolman/spools/unlinked'),
+  getLinkedSpools: () =>
+    request<LinkedSpoolsMap>('/spoolman/spools/linked'),
   linkSpool: (spoolId: number, trayUuid: string) =>
     request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/link`, {
       method: 'POST',
       body: JSON.stringify({ tray_uuid: trayUuid }),
     }),
+  getSpoolmanSettings: () =>
+    request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string }>('/settings/spoolman'),
+  updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string }) =>
+    request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string }>('/settings/spoolman', {
+      method: 'PUT',
+      body: JSON.stringify(data),
+    }),
 
   // Updates
   getVersion: () => request<VersionInfo>('/updates/version'),
@@ -3016,6 +3145,8 @@ export const api = {
     `${API_BASE}/printers/${printerId}/camera/snapshot`,
   testCameraConnection: (printerId: number) =>
     request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
+  getCameraStatus: (printerId: number) =>
+    request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),
 
   // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
   checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
@@ -3220,6 +3351,42 @@ export const api = {
       method: 'POST',
       body: JSON.stringify(data),
     }),
+  importProjectFile: async (file: File): Promise<Project> => {
+    const formData = new FormData();
+    formData.append('file', file);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/projects/import/file`, {
+      method: 'POST',
+      headers,
+      body: formData,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.json();
+  },
+  exportProjectZip: async (projectId: number): Promise<{ blob: Blob; filename: string }> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/projects/${projectId}/export`, {
+      headers,
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const contentDisposition = response.headers.get('Content-Disposition');
+    const filenameMatch = contentDisposition?.match(/filename="(.+)"/);
+    const filename = filenameMatch?.[1] || `project_${projectId}.zip`;
+    const blob = await response.blob();
+    return { blob, filename };
+  },
 
   // API Keys
   getAPIKeys: () => request<APIKey[]>('/api-keys/'),
@@ -3333,6 +3500,29 @@ export const api = {
   deleteLibraryFile: (id: number) =>
     request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
   getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
+  downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/library/files/${id}/download`, { headers });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    const disposition = response.headers.get('Content-Disposition');
+    const filenameMatch = disposition?.match(/filename="?([^";\n]+)"?/);
+    const downloadFilename = filenameMatch?.[1] || filename || `file_${id}`;
+    const blob = await response.blob();
+    const url = window.URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = downloadFilename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    window.URL.revokeObjectURL(url);
+  },
   getLibraryFileThumbnailUrl: (id: number) => `${API_BASE}/library/files/${id}/thumbnail`,
   getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
     `${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`,
@@ -3755,22 +3945,36 @@ export const discoveryApi = {
 };
 
 // Virtual Printer types
+export type VirtualPrinterMode = 'immediate' | 'queue' | 'review' | 'print_queue' | 'proxy';  // 'queue' is legacy, normalized to 'review'
+
+export interface VirtualPrinterProxyStatus {
+  running: boolean;
+  target_host: string;
+  ftp_port: number;
+  mqtt_port: number;
+  ftp_connections: number;
+  mqtt_connections: number;
+}
+
 export interface VirtualPrinterStatus {
   enabled: boolean;
   running: boolean;
-  mode: 'immediate' | 'queue' | 'review' | 'print_queue';  // 'queue' is legacy, normalized to 'review'
+  mode: VirtualPrinterMode;
   name: string;
   serial: string;
   model: string;
   model_name: string;
   pending_files: number;
+  target_printer_ip?: string;  // For proxy mode
+  proxy?: VirtualPrinterProxyStatus;  // For proxy mode
 }
 
 export interface VirtualPrinterSettings {
   enabled: boolean;
   access_code_set: boolean;
-  mode: 'immediate' | 'queue' | 'review' | 'print_queue';  // 'queue' is legacy, normalized to 'review'
+  mode: VirtualPrinterMode;
   model: string;
+  target_printer_id: number | null;  // For proxy mode
   status: VirtualPrinterStatus;
 }
 
@@ -3800,14 +4004,16 @@ export const virtualPrinterApi = {
   updateSettings: (data: {
     enabled?: boolean;
     access_code?: string;
-    mode?: 'immediate' | 'review' | 'print_queue';
+    mode?: 'immediate' | 'review' | 'print_queue' | 'proxy';
     model?: string;
+    target_printer_id?: number;
   }) => {
     const params = new URLSearchParams();
     if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
     if (data.access_code !== undefined) params.set('access_code', data.access_code);
     if (data.mode !== undefined) params.set('mode', data.mode);
     if (data.model !== undefined) params.set('model', data.model);
+    if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
 
     return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
       method: 'PUT',
@@ -3925,7 +4131,11 @@ export const supportApi = {
     }),
 
   downloadSupportBundle: async () => {
-    const response = await fetch(`${API_BASE}/support/bundle`);
+    const headers: Record<string, string> = {};
+    if (authToken) {
+      headers['Authorization'] = `Bearer ${authToken}`;
+    }
+    const response = await fetch(`${API_BASE}/support/bundle`, { headers });
     if (!response.ok) {
       const error = await response.json().catch(() => ({}));
       throw new Error(error.detail || `HTTP ${response.status}`);

+ 43 - 6
frontend/src/components/AddSmartPlugModal.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
-import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio } from 'lucide-react';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Radio, Eye } from 'lucide-react';
 import { api } from '../api/client';
 import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client';
 import { Button } from './Button';
@@ -77,8 +77,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
   const [scheduleOnTime, setScheduleOnTime] = useState<string>(plug?.schedule_on_time || '');
   const [scheduleOffTime, setScheduleOffTime] = useState<string>(plug?.schedule_off_time || '');
 
-  // Switchbar visibility
+  // Visibility options
   const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false);
+  const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true);
 
   // Discovery state
   const [isScanning, setIsScanning] = useState(false);
@@ -251,6 +252,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Also invalidate printer card HA entity queries
+      queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter'] });
       onClose();
     },
     onError: (err: Error) => {
@@ -263,6 +266,8 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Also invalidate printer card HA entity queries
+      queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter'] });
       onClose();
     },
     onError: (err: Error) => {
@@ -270,10 +275,17 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
     },
   });
 
-  // Filter out printers that already have a plug assigned (except current plug's printer)
+  // For Tasmota plugs, only one per printer (physical device)
+  // For HA scripts, allow multiple per printer
   const availablePrinters = printers?.filter(p => {
-    const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id);
-    return !hasPlug;
+    if (plugType === 'tasmota') {
+      const hasTasmotaPlug = existingPlugs?.some(
+        ep => ep.printer_id === p.id && ep.id !== plug?.id && ep.plug_type === 'tasmota'
+      );
+      return !hasTasmotaPlug;
+    }
+    // HA scripts can have multiple per printer
+    return true;
   });
 
   const handleSubmit = (e: React.FormEvent) => {
@@ -339,8 +351,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
       schedule_enabled: scheduleEnabled,
       schedule_on_time: scheduleOnTime || null,
       schedule_off_time: scheduleOffTime || null,
-      // Switchbar
+      // Visibility
       show_in_switchbar: showInSwitchbar,
+      show_on_printer_card: showOnPrinterCard,
     };
 
     if (isEditing) {
@@ -1308,6 +1321,30 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {
             </div>
           </div>
 
+          {/* Printer Card Visibility - only for HA entities */}
+          {plugType === 'homeassistant' && (
+            <div className="border-t border-bambu-dark-tertiary pt-4">
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Eye className="w-4 h-4 text-bambu-green" />
+                  <div>
+                    <span className="text-white font-medium">Show on Printer Card</span>
+                    <p className="text-xs text-bambu-gray">Display button on printer card</p>
+                  </div>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={showOnPrinterCard}
+                    onChange={(e) => setShowOnPrinterCard(e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+            </div>
+          )}
+
           {/* Actions */}
           <div className="flex gap-3 pt-2">
             <Button

+ 10 - 5
frontend/src/components/ConfirmModal.tsx

@@ -1,4 +1,5 @@
 import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import { AlertTriangle, Loader2 } from 'lucide-react';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
@@ -18,14 +19,18 @@ interface ConfirmModalProps {
 export function ConfirmModal({
   title,
   message,
-  confirmText = 'Confirm',
-  cancelText = 'Cancel',
+  confirmText,
+  cancelText,
   variant = 'default',
   isLoading = false,
   loadingText,
   onConfirm,
   onCancel,
 }: ConfirmModalProps) {
+  const { t } = useTranslation();
+  const resolvedConfirmText = confirmText ?? t('common.confirm');
+  const resolvedCancelText = cancelText ?? t('common.cancel');
+  const resolvedLoadingText = loadingText ?? t('common.loading');
   // Close on Escape key (but not while loading)
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -70,7 +75,7 @@ export function ConfirmModal({
           </div>
           <div className="flex gap-3 mt-6">
             <Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
-              {cancelText}
+              {resolvedCancelText}
             </Button>
             <Button
               onClick={onConfirm}
@@ -80,10 +85,10 @@ export function ConfirmModal({
               {isLoading ? (
                 <>
                   <Loader2 className="w-4 h-4 mr-2 animate-spin" />
-                  {loadingText || 'Processing...'}
+                  {resolvedLoadingText}
                 </>
               ) : (
-                confirmText
+                resolvedConfirmText
               )}
             </Button>
           </div>

+ 50 - 50
frontend/src/components/EditArchiveModal.tsx

@@ -1,30 +1,28 @@
 import { useState, useEffect, useRef } from 'react';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react';
 import { api } from '../api/client';
 import type { Archive } from '../api/client';
 import { Button } from './Button';
 
-const FAILURE_REASONS = [
-  'Adhesion failure',
-  'Spaghetti / Detached',
-  'Layer shift',
-  'Clogged nozzle',
-  'Filament runout',
-  'Warping',
-  'Stringing',
-  'Under-extrusion',
-  'Power failure',
-  'User cancelled',
-  'Other',
-];
-
-const ARCHIVE_STATUSES = [
-  { value: 'completed', label: 'Completed' },
-  { value: 'failed', label: 'Failed' },
-  { value: 'aborted', label: 'Cancelled' },
-  { value: 'printing', label: 'Printing' },
-];
+// Keys for failure reasons - translated at render time
+const FAILURE_REASON_KEYS = [
+  'adhesionFailure',
+  'spaghettiDetached',
+  'layerShift',
+  'cloggedNozzle',
+  'filamentRunout',
+  'warping',
+  'stringing',
+  'underExtrusion',
+  'powerFailure',
+  'userCancelled',
+  'other',
+] as const;
+
+// Keys for archive statuses - translated at render time
+const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const;
 
 interface EditArchiveModalProps {
   archive: Archive;
@@ -33,6 +31,8 @@ interface EditArchiveModalProps {
 }
 
 export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) {
+  const { t } = useTranslation();
+
   // Close on Escape key
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -205,7 +205,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
       >
         {/* Header */}
         <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
-          <h2 className="text-lg font-semibold text-white">Edit Archive</h2>
+          <h2 className="text-lg font-semibold text-white">{t('editArchive.title')}</h2>
           <button
             onClick={onClose}
             className="text-bambu-gray hover:text-white transition-colors"
@@ -218,25 +218,25 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
         <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
           {/* Print Name */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">Name</label>
+            <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.name')}</label>
             <input
               type="text"
               value={printName}
               onChange={(e) => setPrintName(e.target.value)}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-              placeholder="Print name"
+              placeholder={t('editArchive.namePlaceholder')}
             />
           </div>
 
           {/* Printer */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">Printer</label>
+            <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.printer')}</label>
             <select
               value={printerId ?? ''}
               onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
             >
-              <option value="">No printer</option>
+              <option value="">{t('editArchive.noPrinter')}</option>
               {printers?.map((p) => (
                 <option key={p.id} value={p.id}>
                   {p.name}
@@ -249,14 +249,14 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           <div>
             <label className="block text-sm text-bambu-gray mb-1">
               <FolderKanban className="w-4 h-4 inline mr-1" />
-              Project
+              {t('editArchive.project')}
             </label>
             <select
               value={projectId ?? ''}
               onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
             >
-              <option value="">No project</option>
+              <option value="">{t('editArchive.noProject')}</option>
               {projects?.map((p) => (
                 <option key={p.id} value={p.id}>
                   {p.name}
@@ -269,7 +269,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           <div>
             <label className="block text-sm text-bambu-gray mb-1">
               <Hash className="w-4 h-4 inline mr-1" />
-              Items Printed
+              {t('editArchive.itemsPrinted')}
             </label>
             <input
               type="number"
@@ -280,19 +280,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               placeholder="1"
             />
             <p className="text-xs text-bambu-gray mt-1">
-              Number of items produced in this print job
+              {t('editArchive.itemsPrintedHelp')}
             </p>
           </div>
 
           {/* Notes */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">Notes</label>
+            <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.notes')}</label>
             <textarea
               value={notes}
               onChange={(e) => setNotes(e.target.value)}
               rows={3}
               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 resize-none"
-              placeholder="Add notes about this print..."
+              placeholder={t('editArchive.notesPlaceholder')}
             />
           </div>
 
@@ -300,7 +300,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           <div>
             <label className="block text-sm text-bambu-gray mb-1">
               <Link className="w-4 h-4 inline mr-1" />
-              External Link
+              {t('editArchive.externalLink')}
             </label>
             <input
               type="url"
@@ -310,13 +310,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               placeholder="https://printables.com/model/..."
             />
             <p className="text-xs text-bambu-gray mt-1">
-              Link to Printables, Thingiverse, or other source
+              {t('editArchive.externalLinkHelp')}
             </p>
           </div>
 
           {/* Tags */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">Tags</label>
+            <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.tags')}</label>
             {/* Current tags as chips */}
             {currentTags.length > 0 && (
               <div className="flex flex-wrap gap-1.5 mb-2">
@@ -355,13 +355,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
                   blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
                 }}
                 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"
-                placeholder={currentTags.length > 0 ? "Add more tags..." : "Add tags..."}
+                placeholder={currentTags.length > 0 ? t('editArchive.addMoreTags') : t('editArchive.tagsPlaceholder')}
               />
               {/* Suggestions dropdown */}
               {showTagSuggestions && tagSuggestions.length > 0 && (
                 <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
                   <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
-                    {currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
+                    {currentInput ? t('editArchive.matchingTags', { query: currentInput }) : t('editArchive.existingTags')} {t('editArchive.clickToAdd')}
                   </div>
                   <div className="p-2 flex flex-wrap gap-1.5">
                     {tagSuggestions.map((tag) => (
@@ -382,7 +382,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
 
           {/* Status */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">Status</label>
+            <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.status')}</label>
             <select
               value={status}
               onChange={(e) => {
@@ -394,9 +394,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               }}
               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"
             >
-              {ARCHIVE_STATUSES.map((s) => (
-                <option key={s.value} value={s.value}>
-                  {s.label}
+              {ARCHIVE_STATUS_KEYS.map((statusKey) => (
+                <option key={statusKey} value={statusKey}>
+                  {t(`editArchive.statuses.${statusKey}`)}
                 </option>
               ))}
             </select>
@@ -405,16 +405,16 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           {/* Failure Reason - only show for failed/aborted prints */}
           {(status === 'failed' || status === 'aborted') && (
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Failure Reason</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
               <select
                 value={failureReason}
                 onChange={(e) => setFailureReason(e.target.value)}
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
               >
-                <option value="">Select reason...</option>
-                {FAILURE_REASONS.map((reason) => (
-                  <option key={reason} value={reason}>
-                    {reason}
+                <option value="">{t('editArchive.selectReason')}</option>
+                {FAILURE_REASON_KEYS.map((reasonKey) => (
+                  <option key={reasonKey} value={t(`editArchive.failureReasons.${reasonKey}`)}>
+                    {t(`editArchive.failureReasons.${reasonKey}`)}
                   </option>
                 ))}
               </select>
@@ -425,7 +425,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           <div>
             <label className="block text-sm text-bambu-gray mb-1">
               <Camera className="w-4 h-4 inline mr-1" />
-              Photos of Printed Result
+              {t('editArchive.photos')}
             </label>
             {/* Photo grid */}
             <div className="flex flex-wrap gap-2 mb-2">
@@ -433,7 +433,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
                 <div key={filename} className="relative group">
                   <img
                     src={api.getArchivePhotoUrl(archive.id, filename)}
-                    alt="Print result"
+                    alt={t('editArchive.printResult')}
                     className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
                   />
                   <button
@@ -462,7 +462,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
                 )}
               </label>
             </div>
-            <p className="text-xs text-bambu-gray">Click + to add photos of your printed result</p>
+            <p className="text-xs text-bambu-gray">{t('editArchive.photosHelp')}</p>
           </div>
 
           {/* Actions */}
@@ -473,7 +473,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               onClick={onClose}
               className="flex-1"
             >
-              Cancel
+              {t('common.cancel')}
             </Button>
             <Button
               type="submit"
@@ -481,7 +481,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               className="flex-1"
             >
               <Save className="w-4 h-4" />
-              {updateMutation.isPending ? 'Saving...' : 'Save'}
+              {updateMutation.isPending ? t('common.saving') : t('common.save')}
             </Button>
           </div>
         </form>

+ 7 - 10
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -184,17 +184,14 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
 
     stallCheckIntervalRef.current = setInterval(async () => {
       try {
-        const response = await fetch(`/api/v1/printers/${printerId}/camera/status`);
-        if (response.ok) {
-          const status = await response.json();
-          if (status.stalled || (!status.active && !streamError)) {
-            if (stallCheckIntervalRef.current) {
-              clearInterval(stallCheckIntervalRef.current);
-              stallCheckIntervalRef.current = null;
-            }
-            setStreamLoading(false);
-            attemptReconnect();
+        const status = await api.getCameraStatus(printerId);
+        if (status.stalled || (!status.active && !streamError)) {
+          if (stallCheckIntervalRef.current) {
+            clearInterval(stallCheckIntervalRef.current);
+            stallCheckIntervalRef.current = null;
           }
+          setStreamLoading(false);
+          attemptReconnect();
         }
       } catch {
         // Ignore errors

+ 34 - 14
frontend/src/components/FilamentHoverCard.tsx

@@ -1,5 +1,6 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
-import { Droplets, Link2, Copy, Check, Settings2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react';
 
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
@@ -15,6 +16,8 @@ interface SpoolmanConfig {
   enabled: boolean;
   onLinkSpool?: (trayUuid: string) => void;
   hasUnlinkedSpools?: boolean; // Whether there are spools available to link
+  linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked
+  spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link)
 }
 
 interface ConfigureSlotConfig {
@@ -36,6 +39,7 @@ interface FilamentHoverCardProps {
  * Replaces the basic browser tooltip with a styled popover.
  */
 export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
+  const { t } = useTranslation();
   const [isVisible, setIsVisible] = useState(false);
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [copied, setCopied] = useState(false);
@@ -199,7 +203,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
               {/* Profile name */}
               <div className="flex items-center justify-between">
                 <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
-                  Profile
+                  {t('ams.profile')}
                 </span>
                 <span className="text-xs text-white font-semibold truncate max-w-[120px]">
                   {data.profile}
@@ -209,7 +213,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
               {/* K Factor */}
               <div className="flex items-center justify-between">
                 <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
-                  K Factor
+                  {t('ams.kFactor')}
                 </span>
                 <span className="text-xs text-bambu-green font-mono font-bold">
                   {data.kFactor}
@@ -221,7 +225,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                 <div className="flex items-center justify-between">
                   <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium flex items-center gap-1">
                     <Droplets className="w-3 h-3" />
-                    Fill
+                    {t('ams.fill')}
                   </span>
                   <span className="text-xs text-white font-semibold">
                     {data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
@@ -249,7 +253,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                   {/* Tray UUID with copy button */}
                   <div className="flex items-center justify-between">
                     <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
-                      Spool ID
+                      {t('spoolman.spoolId')}
                     </span>
                     <button
                       onClick={(e) => {
@@ -270,8 +274,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     </button>
                   </div>
 
-                  {/* Link Spool button */}
-                  {spoolman.onLinkSpool && (
+                  {/* Open in Spoolman button (when already linked) */}
+                  {spoolman.linkedSpoolId && spoolman.spoolmanUrl && (
+                    <a
+                      href={`${spoolman.spoolmanUrl.replace(/\/$/, '')}/spool/show/${spoolman.linkedSpoolId}`}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      onClick={(e) => e.stopPropagation()}
+                      className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
+                      title={t('spoolman.openInSpoolman')}
+                    >
+                      <ExternalLink className="w-3.5 h-3.5" />
+                      {t('spoolman.openInSpoolman')}
+                    </a>
+                  )}
+
+                  {/* Link Spool button (when not linked) */}
+                  {!spoolman.linkedSpoolId && spoolman.onLinkSpool && (
                     <button
                       onClick={(e) => {
                         e.stopPropagation();
@@ -285,10 +304,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                           ? 'bg-bambu-gray/10 text-bambu-gray cursor-not-allowed'
                           : 'bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green'
                       }`}
-                      title={spoolman.hasUnlinkedSpools === false ? 'No unlinked spools available' : 'Link this spool to a Spoolman spool'}
+                      title={spoolman.hasUnlinkedSpools === false ? t('spoolman.noUnlinkedSpools') : t('spoolman.linkToSpoolman')}
                     >
                       <Link2 className="w-3.5 h-3.5" />
-                      Link to Spoolman
+                      {t('spoolman.linkToSpoolman')}
                     </button>
                   )}
                 </div>
@@ -303,10 +322,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                       configureSlot.onConfigure?.();
                     }}
                     className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
-                    title="Configure slot with filament profile and K value"
+                    title={t('ams.configureSlot')}
                   >
                     <Settings2 className="w-3.5 h-3.5" />
-                    Configure
+                    {t('ams.configure')}
                   </button>
                 </div>
               )}
@@ -340,6 +359,7 @@ interface EmptySlotHoverCardProps {
  * Wrapper for empty slots - shows "Empty" on hover with optional configure button
  */
 export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
+  const { t } = useTranslation();
   const [isVisible, setIsVisible] = useState(false);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
@@ -377,7 +397,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot }:
             rounded-md shadow-lg overflow-hidden
           ">
             <div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
-              Empty slot
+              {t('ams.emptySlot')}
             </div>
             {/* Configure slot button */}
             {configureSlot?.enabled && (
@@ -388,10 +408,10 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot }:
                     configureSlot.onConfigure?.();
                   }}
                   className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
-                  title="Configure slot with filament profile and K value"
+                  title={t('ams.configureSlot')}
                 >
                   <Settings2 className="w-3.5 h-3.5" />
-                  Configure
+                  {t('ams.configure')}
                 </button>
               </div>
             )}

+ 16 - 12
frontend/src/components/FilamentTrends.tsx

@@ -61,9 +61,10 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
       const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
 
       const existing = dataMap.get(key) || { date: key, filament: 0, cost: 0, prints: 0 };
-      existing.filament += archive.filament_used_grams || 0;
+      const qty = archive.quantity || 1;
+      existing.filament += (archive.filament_used_grams || 0) * qty;
       existing.cost += archive.cost || 0;
-      existing.prints += 1;
+      existing.prints += qty;
       dataMap.set(key, existing);
     });
 
@@ -89,9 +90,10 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
       const key = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`;
 
       const existing = dataMap.get(key) || { week: key, filament: 0, cost: 0, prints: 0 };
-      existing.filament += archive.filament_used_grams || 0;
+      const qty = archive.quantity || 1;
+      existing.filament += (archive.filament_used_grams || 0) * qty;
       existing.cost += archive.cost || 0;
-      existing.prints += 1;
+      existing.prints += qty;
       dataMap.set(key, existing);
     });
 
@@ -110,10 +112,11 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
 
     filteredArchives.forEach(archive => {
       const type = archive.filament_type || 'Unknown';
+      const qty = archive.quantity || 1;
       // Handle multiple types (e.g., "PLA, PETG")
       const types = type.split(', ');
       types.forEach(t => {
-        const grams = (archive.filament_used_grams || 0) / types.length;
+        const grams = ((archive.filament_used_grams || 0) * qty) / types.length;
         dataMap.set(t, (dataMap.get(t) || 0) + grams);
       });
     });
@@ -140,9 +143,9 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
 
       months.push({
         month: monthStr,
-        filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0)),
+        filament: Math.round(monthArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0) * (a.quantity || 1), 0)),
         cost: monthArchives.reduce((sum, a) => sum + (a.cost || 0), 0),
-        prints: monthArchives.length,
+        prints: monthArchives.reduce((sum, a) => sum + (a.quantity || 1), 0),
       });
     }
 
@@ -150,8 +153,9 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
   }, [archives]);
 
   const chartData = timeRange === '7d' || timeRange === '30d' ? dailyData : weeklyData;
-  const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0), 0);
+  const totalFilament = filteredArchives.reduce((sum, a) => sum + (a.filament_used_grams || 0) * (a.quantity || 1), 0);
   const totalCost = filteredArchives.reduce((sum, a) => sum + (a.cost || 0), 0);
+  const totalPrints = filteredArchives.reduce((sum, a) => sum + (a.quantity || 1), 0);
 
   return (
     <div className="space-y-6">
@@ -185,17 +189,17 @@ export function FilamentTrends({ archives, currency = '$' }: FilamentTrendsProps
         <div className="bg-bambu-dark rounded-lg p-4">
           <p className="text-sm text-bambu-gray">Period Cost</p>
           <p className="text-2xl font-bold text-white">{currency}{totalCost.toFixed(2)}</p>
-          <p className="text-xs text-bambu-gray">{filteredArchives.length} prints</p>
+          <p className="text-xs text-bambu-gray">{totalPrints} prints</p>
         </div>
         <div className="bg-bambu-dark rounded-lg p-4">
           <p className="text-sm text-bambu-gray">Avg per Print</p>
           <p className="text-2xl font-bold text-white">
-            {filteredArchives.length > 0
-              ? (totalFilament / filteredArchives.length).toFixed(0)
+            {totalPrints > 0
+              ? (totalFilament / totalPrints).toFixed(0)
               : 0}g
           </p>
           <p className="text-xs text-bambu-gray">
-            {currency}{filteredArchives.length > 0 ? (totalCost / filteredArchives.length).toFixed(2) : '0.00'} avg
+            {currency}{totalPrints > 0 ? (totalCost / totalPrints).toFixed(2) : '0.00'} avg
           </p>
         </div>
       </div>

+ 17 - 13
frontend/src/components/FileManagerModal.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import {
   X,
   Folder,
@@ -285,6 +286,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
 ];
 
 export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
+  const { t } = useTranslation();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const [currentPath, setCurrentPath] = useState('/');
@@ -323,13 +325,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
       }
     },
     onSuccess: () => {
-      showToast(`Deleted ${filesToDelete.length} file${filesToDelete.length > 1 ? 's' : ''}`);
+      showToast(t('printerFiles.toast.filesDeleted', { count: filesToDelete.length }));
       queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
       setSelectedFiles(new Set());
       setFilesToDelete([]);
     },
     onError: (error: Error) => {
-      showToast(`Delete failed: ${error.message}`, 'error');
+      showToast(t('printerFiles.toast.deleteFailed', { error: error.message }), 'error');
     },
   });
 
@@ -377,8 +379,10 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
     const paths = Array.from(selectedFiles);
 
     if (paths.length === 1) {
-      // Single file - direct download
-      window.open(api.getPrinterFileDownloadUrl(printerId, paths[0]), '_blank');
+      // Single file - direct download with auth
+      api.downloadPrinterFile(printerId, paths[0]).catch((err) => {
+        console.error('Printer file download failed:', err);
+      });
       setSelectedFiles(new Set());
       return;
     }
@@ -431,7 +435,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
             <div className="flex items-center gap-3">
               <HardDrive className="w-5 h-5 text-bambu-green" />
               <div>
-                <h2 className="text-lg font-semibold text-white">File Manager</h2>
+                <h2 className="text-lg font-semibold text-white">{t('printerFiles.title')}</h2>
                 <p className="text-sm text-bambu-gray">{printerName}</p>
               </div>
             </div>
@@ -440,13 +444,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               {storageData && (storageData.used_bytes != null || storageData.free_bytes != null) && (
                 <div className="text-sm text-bambu-gray flex items-center gap-2">
                   {storageData.used_bytes != null && (
-                    <span>Used: {formatStorageSize(storageData.used_bytes)}</span>
+                    <span>{t('printerFiles.storageUsed')} {formatStorageSize(storageData.used_bytes)}</span>
                   )}
                   {storageData.used_bytes != null && storageData.free_bytes != null && (
                     <span className="text-bambu-dark-tertiary">|</span>
                   )}
                   {storageData.free_bytes != null && (
-                    <span>Free: {formatStorageSize(storageData.free_bytes)}</span>
+                    <span>{t('printerFiles.storageFree')} {formatStorageSize(storageData.free_bytes)}</span>
                   )}
                 </div>
               )}
@@ -484,7 +488,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
             <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
             <input
               type="text"
-              placeholder="Filter files..."
+              placeholder={t('printerFiles.filterPlaceholder')}
               value={searchQuery}
               onChange={(e) => setSearchQuery(e.target.value)}
               className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
@@ -705,7 +709,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               ) : (
                 <Trash2 className="w-4 h-4" />
               )}
-              Delete{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
+              {t('printerFiles.deleteButton')}{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
             </Button>
           </div>
         </div>
@@ -714,13 +718,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
       {/* Delete Confirmation Modal */}
       {filesToDelete.length > 0 && (
         <ConfirmModal
-          title={filesToDelete.length > 1 ? `Delete ${filesToDelete.length} Files` : 'Delete File'}
+          title={filesToDelete.length > 1 ? t('printerFiles.deleteFiles', { count: filesToDelete.length }) : t('fileManager.deleteFile')}
           message={
             filesToDelete.length > 1
-              ? `Delete ${filesToDelete.length} selected files? This cannot be undone.`
-              : `Delete "${filesToDelete[0].split('/').pop()}"? This cannot be undone.`
+              ? t('printerFiles.deleteFilesConfirm', { count: filesToDelete.length })
+              : t('printerFiles.deleteFileConfirm', { name: filesToDelete[0].split('/').pop() })
           }
-          confirmText="Delete"
+          confirmText={t('common.delete')}
           variant="danger"
           onConfirm={() => {
             deleteMutation.mutate(filesToDelete);

+ 7 - 2
frontend/src/components/GitHubBackupSettings.tsx

@@ -1,4 +1,5 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
 import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
   Github,
@@ -96,6 +97,7 @@ function formatRelativeTime(dateStr: string | null): string {
 export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const { t } = useTranslation();
 
   // Local state for form
   const [repoUrl, setRepoUrl] = useState('');
@@ -762,9 +764,12 @@ export function GitHubBackupSettings() {
             {/* Import */}
             <div className="flex items-center justify-between py-3 border-b border-bambu-dark-tertiary">
               <div>
-                <p className="text-white">Restore Backup</p>
+                <p className="text-white">{t('backup.restoreBackup')}</p>
                 <p className="text-sm text-bambu-gray">
-                  Replace all data from a backup file
+                  {t('backup.restoreDescription')}
+                </p>
+                <p className="text-xs text-bambu-gray-light mt-1">
+                  {t('backup.restoreNote')}
                 </p>
               </div>
               <input

+ 7 - 4
frontend/src/components/HMSErrorModal.tsx

@@ -1,6 +1,7 @@
 // HMS Error Modal - Comprehensive error code database
 // Source: https://github.com/greghesp/ha-bambulab
 import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
 import type { HMSError } from '../api/client';
 
@@ -904,6 +905,8 @@ function getHMSHomeUrl(): string {
 }
 
 export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
+  const { t } = useTranslation();
+
   // Debug: log errors to see what data we're receiving
   console.log('HMSErrorModal errors:', JSON.stringify(errors, null, 2));
 
@@ -930,7 +933,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div className="flex items-center gap-2">
             <AlertTriangle className="w-5 h-5 text-orange-400" />
-            <h2 className="text-lg font-semibold text-white">Errors - {printerName}</h2>
+            <h2 className="text-lg font-semibold text-white">{t('hmsErrors.title', { name: printerName })}</h2>
           </div>
           <button
             onClick={onClose}
@@ -945,7 +948,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
           {knownErrors.length === 0 ? (
             <div className="text-center py-8 text-bambu-gray">
               <AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-30" />
-              <p>No errors</p>
+              <p>{t('hmsErrors.noErrors')}</p>
             </div>
           ) : (
             <div className="space-y-3">
@@ -979,7 +982,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
                           className="inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
                         >
                           <ExternalLink className="w-3 h-3" />
-                          View on Bambu Lab Wiki
+                          {t('hmsErrors.viewOnWiki')}
                         </a>
                       </div>
                     </div>
@@ -993,7 +996,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
         {/* Footer */}
         <div className="p-4 border-t border-bambu-dark-tertiary">
           <p className="text-xs text-bambu-gray">
-            Clear errors on the printer to dismiss them here.
+            {t('hmsErrors.clearInstructions')}
           </p>
         </div>
       </div>

+ 97 - 95
frontend/src/components/KProfilesView.tsx

@@ -1,5 +1,6 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import {
   Gauge,
   Loader2,
@@ -171,6 +172,7 @@ function KProfileModal({
   onSaveNote,
   hasPermission,
 }: KProfileModalProps) {
+  const { t } = useTranslation();
   const { showToast } = useToast();
 
   const [name, setName] = useState(profile?.name || '');
@@ -219,7 +221,7 @@ function KProfileModal({
     },
     onSuccess: (result) => {
       console.log('[KProfile] Save success:', result);
-      showToast('K-profile saved');
+      showToast(t('kProfiles.toast.profileSaved'));
       // Save note if it changed (including clearing it)
       if (onSaveNote && note !== initialNote) {
         let profileKey: string;
@@ -258,7 +260,7 @@ function KProfileModal({
     },
     onSuccess: (result) => {
       console.log('[KProfile] Delete success:', result);
-      showToast('K-profile deleted');
+      showToast(t('kProfiles.toast.profileDeleted'));
       // Show syncing indicator while printer processes the command
       setIsSyncing(true);
       // Add longer delay for delete - printer needs more time to process
@@ -294,7 +296,7 @@ function KProfileModal({
 
     // Validate at least one extruder is selected for dual-nozzle
     if (isDualNozzle && !profile && selectedExtruders.length === 0) {
-      showToast('Please select at least one extruder', 'error');
+      showToast(t('kProfiles.toast.selectAtLeastOneExtruder'), 'error');
       return;
     }
 
@@ -340,7 +342,7 @@ function KProfileModal({
 
     try {
       await api.setKProfilesBatch(printerId, batchPayload);
-      showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
+      showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
       // Save note for new batch profiles
       if (onSaveNote && note) {
         const profileKey = `name_${name}_${filamentId}`;
@@ -348,7 +350,7 @@ function KProfileModal({
       }
     } catch (error) {
       console.error('[KProfile] Failed to save batch:', error);
-      showToast('Failed to save K-profiles', 'error');
+      showToast(t('kProfiles.toast.failedToSaveBatch'), 'error');
       setIsSyncing(false);
       setSavingProgress({ current: 0, total: 0 });
       return;
@@ -373,16 +375,16 @@ function KProfileModal({
             <Loader2 className="w-8 h-8 text-bambu-green animate-spin mb-3" />
             <p className="text-white font-medium">
               {savingProgress.total > 1
-                ? `Saving to extruder ${savingProgress.current}/${savingProgress.total}...`
-                : 'Syncing with printer...'}
+                ? t('kProfiles.modal.savingExtruder', { current: savingProgress.current, total: savingProgress.total })
+                : t('kProfiles.modal.syncing')}
             </p>
-            <p className="text-bambu-gray text-sm mt-1">Please wait</p>
+            <p className="text-bambu-gray text-sm mt-1">{t('kProfiles.modal.pleaseWait')}</p>
           </div>
         )}
         <CardContent className="p-0">
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
             <h2 className="text-xl font-semibold text-white">
-              {profile ? 'Edit K-Profile' : 'Add K-Profile'}
+              {profile ? t('kProfiles.modal.editTitle') : t('kProfiles.modal.addTitle')}
             </h2>
             <button
               onClick={onClose}
@@ -396,21 +398,21 @@ function KProfileModal({
           <form onSubmit={handleSubmit} className="p-4 space-y-4">
             {/* Profile Name - read-only when editing */}
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Profile Name</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.profileName')}</label>
               <input
                 type="text"
                 value={name}
                 onChange={(e) => setName(e.target.value)}
                 disabled={!!profile}
                 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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
-                placeholder="My PLA Profile"
+                placeholder={t('kProfiles.modal.profileNamePlaceholder')}
                 required={!profile}
               />
             </div>
 
             {/* K-Value - always editable */}
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">K-Value</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.kValue')}</label>
               <input
                 type="text"
                 inputMode="decimal"
@@ -430,17 +432,17 @@ function KProfileModal({
                   }
                 }}
                 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 font-mono"
-                placeholder="0.020"
+                placeholder={t('kProfiles.modal.kValuePlaceholder')}
                 required
               />
               <p className="text-xs text-bambu-gray mt-1">
-                Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG
+                {t('kProfiles.modal.kValueHelp')}
               </p>
             </div>
 
             {/* Filament - read-only when editing */}
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Filament</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
               <select
                 value={filamentId}
                 onChange={(e) => {
@@ -460,7 +462,7 @@ function KProfileModal({
                 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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
                 required={!profile}
               >
-                <option value="">Select filament...</option>
+                <option value="">{t('kProfiles.modal.selectFilament')}</option>
                 {/* Show current filament when editing - look up from knownFilaments */}
                 {profile?.filament_id && (
                   <option key={profile.filament_id} value={profile.filament_id}>
@@ -476,7 +478,7 @@ function KProfileModal({
               </select>
               {!profile && knownFilaments.length === 0 && (
                 <p className="text-xs text-bambu-gray mt-1">
-                  No filaments found. Create a K-profile in Bambu Studio first.
+                  {t('kProfiles.modal.noFilamentsHelp')}
                 </p>
               )}
             </div>
@@ -484,7 +486,7 @@ function KProfileModal({
             {/* Flow Type and Nozzle Size - read-only when editing */}
             <div className="grid grid-cols-2 gap-4">
               <div>
-                <label className="block text-sm text-bambu-gray mb-1">Flow Type</label>
+                <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
                 <select
                   value={nozzleType}
                   onChange={(e) => {
@@ -503,12 +505,12 @@ function KProfileModal({
                   disabled={!!profile}
                   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 ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
                 >
-                  <option value="HH00">High Flow</option>
-                  <option value="HS00">Standard</option>
+                  <option value="HH00">{t('kProfiles.modal.highFlow')}</option>
+                  <option value="HS00">{t('kProfiles.modal.standard')}</option>
                 </select>
               </div>
               <div>
-                <label className="block text-sm text-bambu-gray mb-1">Nozzle Size</label>
+                <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.nozzleSize')}</label>
                 <select
                   value={modalDiameter}
                   onChange={(e) => setModalDiameter(e.target.value)}
@@ -527,12 +529,12 @@ function KProfileModal({
             {isDualNozzle && (
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">
-                  {profile ? 'Extruder' : 'Extruders'}
+                  {profile ? t('kProfiles.modal.extruder') : t('kProfiles.modal.extruders')}
                 </label>
                 {profile ? (
                   // Read-only display for editing
                   <div className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
-                    {profile.extruder_id === 1 ? 'Left' : 'Right'}
+                    {profile.extruder_id === 1 ? t('kProfiles.modal.left') : t('kProfiles.modal.right')}
                   </div>
                 ) : (
                   // Checkboxes for new profile - can select both
@@ -550,7 +552,7 @@ function KProfileModal({
                         }}
                         className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
                       />
-                      <span className="text-white">Left</span>
+                      <span className="text-white">{t('kProfiles.modal.left')}</span>
                     </label>
                     <label className="flex items-center gap-2 cursor-pointer">
                       <input
@@ -565,7 +567,7 @@ function KProfileModal({
                         }}
                         className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
                       />
-                      <span className="text-white">Right</span>
+                      <span className="text-white">{t('kProfiles.modal.right')}</span>
                     </label>
                   </div>
                 )}
@@ -574,16 +576,16 @@ function KProfileModal({
 
             {/* Notes */}
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Notes (stored locally)</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.notes')}</label>
               <textarea
                 value={note}
                 onChange={(e) => setNote(e.target.value)}
-                placeholder="Add notes about this profile..."
+                placeholder={t('kProfiles.modal.notesPlaceholder')}
                 rows={2}
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none"
               />
               <p className="text-xs text-bambu-gray mt-1">
-                Notes are saved in Bambuddy, not on the printer
+                {t('kProfiles.modal.notesHelp')}
               </p>
             </div>
 
@@ -594,7 +596,7 @@ function KProfileModal({
                   variant="secondary"
                   onClick={() => setShowDeleteConfirm(true)}
                   disabled={deleteMutation.isPending || isSyncing || !hasPermission('kprofiles:delete')}
-                  title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete K-profiles' : undefined}
+                  title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
                   className="text-red-500 hover:bg-red-500/10"
                 >
                   {deleteMutation.isPending ? (
@@ -611,12 +613,12 @@ function KProfileModal({
                 disabled={isSyncing}
                 className="flex-1"
               >
-                Cancel
+                {t('common.cancel')}
               </Button>
               <Button
                 type="submit"
                 disabled={saveMutation.isPending || isSyncing || !hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create')}
-                title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? `You do not have permission to ${profile ? 'update' : 'create'} K-profiles` : undefined}
+                title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? t(profile ? 'kProfiles.permission.noUpdate' : 'kProfiles.permission.noCreate') : undefined}
                 className="flex-1"
               >
                 {saveMutation.isPending ? (
@@ -624,7 +626,7 @@ function KProfileModal({
                 ) : (
                   <Gauge className="w-4 h-4" />
                 )}
-                Save
+                {t('common.save')}
               </Button>
             </div>
           </form>
@@ -641,12 +643,12 @@ function KProfileModal({
                   <Trash2 className="w-5 h-5 text-red-500" />
                 </div>
                 <div>
-                  <h3 className="text-lg font-semibold text-white">Delete Profile</h3>
-                  <p className="text-sm text-bambu-gray">This cannot be undone</p>
+                  <h3 className="text-lg font-semibold text-white">{t('kProfiles.deleteConfirm.title')}</h3>
+                  <p className="text-sm text-bambu-gray">{t('kProfiles.deleteConfirm.cannotUndo')}</p>
                 </div>
               </div>
               <p className="text-bambu-gray mb-6">
-                Are you sure you want to delete <span className="text-white font-medium">"{profile?.name}"</span> from the printer?
+                {t('kProfiles.deleteConfirm.message', { name: profile?.name })}
               </p>
               <div className="flex gap-3">
                 <Button
@@ -654,7 +656,7 @@ function KProfileModal({
                   onClick={() => setShowDeleteConfirm(false)}
                   className="flex-1"
                 >
-                  Cancel
+                  {t('common.cancel')}
                 </Button>
                 <Button
                   onClick={() => {
@@ -669,7 +671,7 @@ function KProfileModal({
                   ) : (
                     <Trash2 className="w-4 h-4" />
                   )}
-                  Delete
+                  {t('common.delete')}
                 </Button>
               </div>
             </CardContent>
@@ -691,6 +693,7 @@ const STORAGE_KEYS = {
 };
 
 export function KProfilesView() {
+  const { t } = useTranslation();
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
@@ -882,7 +885,7 @@ export function KProfilesView() {
   // Export profiles to JSON file
   const handleExport = useCallback(() => {
     if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {
-      showToast('No profiles to export', 'error');
+      showToast(t('kProfiles.toast.noProfilesToExport'), 'error');
       return;
     }
 
@@ -910,8 +913,8 @@ export function KProfilesView() {
     a.click();
     document.body.removeChild(a);
     URL.revokeObjectURL(url);
-    showToast(`Exported ${kprofiles.profiles.length} profiles`);
-  }, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast]);
+    showToast(t('kProfiles.toast.exportedProfiles', { count: kprofiles.profiles.length }));
+  }, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast, t]);
 
   // Import profiles from JSON file
   const handleImport = useCallback(() => {
@@ -927,7 +930,7 @@ export function KProfilesView() {
         const data = JSON.parse(text);
 
         if (!data.profiles || !Array.isArray(data.profiles)) {
-          showToast('Invalid file format', 'error');
+          showToast(t('kProfiles.toast.invalidFileFormat'), 'error');
           return;
         }
 
@@ -954,15 +957,15 @@ export function KProfilesView() {
           }
         }
 
-        showToast(`Imported ${imported} of ${data.profiles.length} profiles`);
+        showToast(t('kProfiles.toast.importedProfiles', { count: imported, total: data.profiles.length }));
         refetchProfiles();
       } catch (err) {
         console.error('Import error:', err);
-        showToast('Failed to parse import file', 'error');
+        showToast(t('kProfiles.toast.failedToParseImport'), 'error');
       }
     };
     input.click();
-  }, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles]);
+  }, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles, t]);
 
   // Toggle profile selection using composite key
   const toggleProfileSelection = useCallback((profileKey: string) => {
@@ -1012,13 +1015,13 @@ export function KProfilesView() {
       }
     }
 
-    showToast(`Deleted ${deleted} profiles`);
+    showToast(t('kProfiles.toast.profilesDeleted', { count: deleted }));
     setBulkDeleteInProgress(false);
     setShowBulkDeleteConfirm(false);
     setSelectionMode(false);
     setSelectedProfiles(new Set());
     refetchProfiles();
-  }, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey]);
+  }, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey, t]);
 
   // Generate possible keys for a profile (for notes lookup)
   // Returns array of keys to check: setting_id, slot-based, name-based
@@ -1042,9 +1045,9 @@ export function KProfilesView() {
       refetchNotes();
     } catch (err) {
       console.error('Failed to save note:', err);
-      showToast('Failed to save note', 'error');
+      showToast(t('kProfiles.toast.failedToSaveNote'), 'error');
     }
-  }, [selectedPrinter, refetchNotes, showToast]);
+  }, [selectedPrinter, refetchNotes, showToast, t]);
 
   // Get note for a profile (checks all possible keys)
   // Returns { note, key } so we know which key the note was stored under
@@ -1077,9 +1080,9 @@ export function KProfilesView() {
       <Card>
         <CardContent className="py-12 text-center">
           <AlertCircle className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-          <h3 className="text-lg font-semibold text-white mb-2">No Printers Configured</h3>
+          <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noPrintersConfigured')}</h3>
           <p className="text-bambu-gray">
-            Add a printer in Settings to manage K-profiles
+            {t('kProfiles.addPrinterInSettings')}
           </p>
         </CardContent>
       </Card>
@@ -1091,9 +1094,9 @@ export function KProfilesView() {
       <Card>
         <CardContent className="py-12 text-center">
           <Printer className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-          <h3 className="text-lg font-semibold text-white mb-2">No Active Printers</h3>
+          <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noActivePrinters')}</h3>
           <p className="text-bambu-gray">
-            Enable a printer connection to view its K-profiles
+            {t('kProfiles.enablePrinterConnection')}
           </p>
         </CardContent>
       </Card>
@@ -1106,14 +1109,14 @@ export function KProfilesView() {
       {isFetching && !kprofilesLoading && (
         <div className="fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40">
           <Loader2 className="w-10 h-10 text-bambu-green animate-spin mb-3" />
-          <p className="text-white font-medium">Loading K-Profiles...</p>
+          <p className="text-white font-medium">{t('kProfiles.loadingProfiles')}</p>
         </div>
       )}
 
       {/* Printer & Nozzle Selector */}
       <div className="flex flex-wrap gap-4 mb-6">
         <div className="flex-1 min-w-48">
-          <label className="block text-sm text-bambu-gray mb-1">Printer</label>
+          <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.printer')}</label>
           <select
             value={selectedPrinter || ''}
             onChange={(e) => setSelectedPrinter(parseInt(e.target.value))}
@@ -1128,7 +1131,7 @@ export function KProfilesView() {
         </div>
 
         <div className="w-32">
-          <label className="block text-sm text-bambu-gray mb-1">Nozzle</label>
+          <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.nozzle')}</label>
           <select
             value={nozzleDiameter}
             onChange={(e) => setNozzleDiameter(e.target.value)}
@@ -1146,18 +1149,18 @@ export function KProfilesView() {
             variant="secondary"
             onClick={() => refetchProfiles()}
             disabled={isFetching || !hasPermission('kprofiles:read')}
-            title={!hasPermission('kprofiles:read') ? 'You do not have permission to refresh profiles' : undefined}
+            title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noRead') : undefined}
           >
             <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
-            Refresh
+            {t('kProfiles.refresh')}
           </Button>
           <Button
             onClick={() => setShowAddModal(true)}
             disabled={!hasPermission('kprofiles:create')}
-            title={!hasPermission('kprofiles:create') ? 'You do not have permission to add profiles' : undefined}
+            title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noCreate') : undefined}
           >
             <Plus className="w-4 h-4" />
-            Add Profile
+            {t('kProfiles.addProfile')}
           </Button>
         </div>
       </div>
@@ -1170,7 +1173,7 @@ export function KProfilesView() {
             type="text"
             value={searchQuery}
             onChange={(e) => setSearchQuery(e.target.value)}
-            placeholder="Search by name or filament..."
+            placeholder={t('kProfiles.searchPlaceholder')}
             className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
           />
         </div>
@@ -1181,9 +1184,9 @@ export function KProfilesView() {
               onChange={(e) => setExtruderFilter(e.target.value as ExtruderFilter)}
               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="all">All Extruders</option>
-              <option value="left">Left Only</option>
-              <option value="right">Right Only</option>
+              <option value="all">{t('kProfiles.allExtruders')}</option>
+              <option value="left">{t('kProfiles.leftOnly')}</option>
+              <option value="right">{t('kProfiles.rightOnly')}</option>
             </select>
           </div>
         )}
@@ -1193,9 +1196,9 @@ export function KProfilesView() {
             onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
             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="all">All Flow</option>
-            <option value="hf">HF Only</option>
-            <option value="s">S Only</option>
+            <option value="all">{t('kProfiles.allFlow')}</option>
+            <option value="hf">{t('kProfiles.hfOnly')}</option>
+            <option value="s">{t('kProfiles.sOnly')}</option>
           </select>
         </div>
         <div className="w-32">
@@ -1204,9 +1207,9 @@ export function KProfilesView() {
             onChange={(e) => setSortOption(e.target.value as SortOption)}
             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="name">Sort: Name</option>
-            <option value="k_value">Sort: K-Value</option>
-            <option value="filament">Sort: Filament</option>
+            <option value="name">{t('kProfiles.sortName')}</option>
+            <option value="k_value">{t('kProfiles.sortKValue')}</option>
+            <option value="filament">{t('kProfiles.sortFilament')}</option>
           </select>
         </div>
       </div>
@@ -1217,19 +1220,19 @@ export function KProfilesView() {
           variant="secondary"
           onClick={handleExport}
           disabled={!kprofiles?.profiles?.length || !hasPermission('kprofiles:read')}
-          title={!hasPermission('kprofiles:read') ? 'You do not have permission to export profiles' : 'Export profiles to JSON'}
+          title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noExport') : undefined}
         >
           <Download className="w-4 h-4" />
-          Export
+          {t('kProfiles.export')}
         </Button>
         <Button
           variant="secondary"
           onClick={handleImport}
           disabled={!hasPermission('kprofiles:create')}
-          title={!hasPermission('kprofiles:create') ? 'You do not have permission to import profiles' : 'Import profiles from JSON'}
+          title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noImport') : undefined}
         >
           <Upload className="w-4 h-4" />
-          Import
+          {t('kProfiles.import')}
         </Button>
         <div className="flex-1" />
         {selectionMode ? (
@@ -1237,20 +1240,19 @@ export function KProfilesView() {
             <Button
               variant="secondary"
               onClick={selectAllProfiles}
-              title="Select all visible profiles"
             >
               <CheckSquare className="w-4 h-4" />
-              Select All
+              {t('kProfiles.selectAll')}
             </Button>
             <Button
               variant="secondary"
               onClick={handleBulkDelete}
               disabled={selectedProfiles.size === 0 || !hasPermission('kprofiles:delete')}
               className="text-red-500 hover:bg-red-500/10"
-              title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : `Delete ${selectedProfiles.size} selected profiles`}
+              title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
             >
               <Trash2 className="w-4 h-4" />
-              Delete ({selectedProfiles.size})
+              {t('kProfiles.delete')} ({selectedProfiles.size})
             </Button>
             <Button
               variant="secondary"
@@ -1260,7 +1262,7 @@ export function KProfilesView() {
               }}
             >
               <X className="w-4 h-4" />
-              Cancel
+              {t('common.cancel')}
             </Button>
           </>
         ) : (
@@ -1268,10 +1270,10 @@ export function KProfilesView() {
             variant="secondary"
             onClick={() => setSelectionMode(true)}
             disabled={!filteredProfiles.length || !hasPermission('kprofiles:delete')}
-            title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : 'Enter selection mode for bulk delete'}
+            title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
           >
             <CheckSquare className="w-4 h-4" />
-            Select
+            {t('kProfiles.select')}
           </Button>
         )}
       </div>
@@ -1285,13 +1287,13 @@ export function KProfilesView() {
         <Card>
           <CardContent className="py-12 text-center">
             <WifiOff className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-            <h3 className="text-lg font-semibold text-white mb-2">Printer Offline</h3>
+            <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.printerOffline')}</h3>
             <p className="text-bambu-gray mb-4">
-              The selected printer is not connected. Power it on to view K-profiles.
+              {t('kProfiles.printerOfflineDesc')}
             </p>
             <Button variant="secondary" onClick={() => refetchProfiles()}>
               <RefreshCw className="w-4 h-4" />
-              Retry
+              {t('common.refresh')}
             </Button>
           </CardContent>
         </Card>
@@ -1301,7 +1303,7 @@ export function KProfilesView() {
           <div className="grid grid-cols-2 gap-4">
             {/* Left Extruder (extruder_id 1 on Bambu) */}
             <div>
-              <h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Left Extruder</h3>
+              <h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.leftExtruder')}</h3>
               <div className="space-y-1">
                 {filteredProfiles
                   .filter((p) => p.extruder_id === 1)
@@ -1321,7 +1323,7 @@ export function KProfilesView() {
             </div>
             {/* Right Extruder (extruder_id 0 on Bambu) */}
             <div>
-              <h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Right Extruder</h3>
+              <h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.rightExtruder')}</h3>
               <div className="space-y-1">
                 {filteredProfiles
                   .filter((p) => p.extruder_id === 0)
@@ -1361,9 +1363,9 @@ export function KProfilesView() {
         <Card>
           <CardContent className="py-12 text-center">
             <Search className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-            <h3 className="text-lg font-semibold text-white mb-2">No Matching Profiles</h3>
+            <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noMatchingProfiles')}</h3>
             <p className="text-bambu-gray">
-              No profiles match your search criteria
+              {t('kProfiles.noMatchingProfilesDesc')}
             </p>
           </CardContent>
         </Card>
@@ -1371,13 +1373,13 @@ export function KProfilesView() {
         <Card>
           <CardContent className="py-12 text-center">
             <Gauge className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-            <h3 className="text-lg font-semibold text-white mb-2">No K-Profiles</h3>
+            <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noKProfiles')}</h3>
             <p className="text-bambu-gray mb-4">
-              No pressure advance profiles found for {nozzleDiameter}mm nozzle
+              {t('kProfiles.noKProfilesDesc', { diameter: nozzleDiameter })}
             </p>
             <Button onClick={() => setShowAddModal(true)}>
               <Plus className="w-4 h-4" />
-              Create First Profile
+              {t('kProfiles.createFirstProfile')}
             </Button>
           </CardContent>
         </Card>
@@ -1466,12 +1468,12 @@ export function KProfilesView() {
                   <Trash2 className="w-5 h-5 text-red-500" />
                 </div>
                 <div>
-                  <h3 className="text-lg font-semibold text-white">Delete Profiles</h3>
-                  <p className="text-sm text-bambu-gray">This cannot be undone</p>
+                  <h3 className="text-lg font-semibold text-white">{t('kProfiles.bulkDelete.title')}</h3>
+                  <p className="text-sm text-bambu-gray">{t('kProfiles.bulkDelete.cannotUndo')}</p>
                 </div>
               </div>
               <p className="text-bambu-gray mb-6">
-                Are you sure you want to delete <span className="text-white font-medium">{selectedProfiles.size}</span> selected profiles from the printer?
+                {t('kProfiles.bulkDelete.message', { count: selectedProfiles.size })}
               </p>
               <div className="flex gap-3">
                 <Button
@@ -1480,7 +1482,7 @@ export function KProfilesView() {
                   disabled={bulkDeleteInProgress}
                   className="flex-1"
                 >
-                  Cancel
+                  {t('common.cancel')}
                 </Button>
                 <Button
                   onClick={executeBulkDelete}
@@ -1492,7 +1494,7 @@ export function KProfilesView() {
                   ) : (
                     <Trash2 className="w-4 h-4" />
                   )}
-                  Delete
+                  {t('common.delete')}
                 </Button>
               </div>
             </CardContent>

+ 20 - 21
frontend/src/components/Layout.tsx

@@ -619,7 +619,7 @@ export function Layout() {
                     <button
                       onClick={() => setShowChangePasswordModal(true)}
                       className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                      title="Change Password"
+                      title={t('changePassword.title')}
                     >
                       <Key className="w-5 h-5" />
                     </button>
@@ -723,7 +723,7 @@ export function Layout() {
                   <button
                     onClick={() => setShowChangePasswordModal(true)}
                     className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                    title="Change Password"
+                    title={t('changePassword.title')}
                   >
                     <Key className="w-5 h-5" />
                   </button>
@@ -824,20 +824,19 @@ export function Layout() {
                 </svg>
               </div>
               <h2 className="text-xl font-bold text-yellow-400 mb-2">
-                Print Paused!
+                {t('plateAlert.title')}
               </h2>
               <p className="text-lg text-white mb-2">
                 {plateDetectionAlert.printer_name}
               </p>
               <p className="text-bambu-gray mb-6">
-                Objects detected on build plate. The print has been automatically paused.
-                Please clear the plate and resume the print.
+                {t('plateAlert.message')}
               </p>
               <button
                 onClick={() => setPlateDetectionAlert(null)}
                 className="w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors"
               >
-                I Understand
+                {t('plateAlert.understand')}
               </button>
             </div>
           </div>
@@ -861,7 +860,7 @@ export function Layout() {
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
                   <Key className="w-5 h-5 text-bambu-green" />
-                  <h2 className="text-lg font-semibold text-white">Change Password</h2>
+                  <h2 className="text-lg font-semibold text-white">{t('changePassword.title')}</h2>
                 </div>
                 <Button
                   variant="ghost"
@@ -879,34 +878,34 @@ export function Layout() {
               <div className="space-y-4">
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Current Password
+                    {t('changePassword.currentPassword')}
                   </label>
                   <input
                     type="password"
                     value={changePasswordData.currentPassword}
                     onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder="Enter current password"
+                    placeholder={t('changePassword.currentPasswordPlaceholder')}
                     autoComplete="current-password"
                   />
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    New Password
+                    {t('changePassword.newPassword')}
                   </label>
                   <input
                     type="password"
                     value={changePasswordData.newPassword}
                     onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder="Enter new password (min 6 characters)"
+                    placeholder={t('changePassword.newPasswordPlaceholder')}
                     autoComplete="new-password"
                     minLength={6}
                   />
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Confirm New Password
+                    {t('changePassword.confirmPassword')}
                   </label>
                   <input
                     type="password"
@@ -917,12 +916,12 @@ export function Layout() {
                         ? 'border-red-500'
                         : 'border-bambu-dark-tertiary'
                     }`}
-                    placeholder="Confirm new password"
+                    placeholder={t('changePassword.confirmPasswordPlaceholder')}
                     autoComplete="new-password"
                     minLength={6}
                   />
                   {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
-                    <p className="text-red-400 text-xs mt-1">Passwords do not match</p>
+                    <p className="text-red-400 text-xs mt-1">{t('changePassword.passwordsDoNotMatch')}</p>
                   )}
                 </div>
               </div>
@@ -934,26 +933,26 @@ export function Layout() {
                     setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
                   }}
                 >
-                  Cancel
+                  {t('common.cancel')}
                 </Button>
                 <Button
                   onClick={async () => {
                     if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
-                      showToast('Passwords do not match', 'error');
+                      showToast(t('changePassword.passwordsDoNotMatch'), 'error');
                       return;
                     }
                     if (changePasswordData.newPassword.length < 6) {
-                      showToast('Password must be at least 6 characters', 'error');
+                      showToast(t('changePassword.passwordTooShort'), 'error');
                       return;
                     }
                     setChangePasswordLoading(true);
                     try {
                       await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
-                      showToast('Password changed successfully', 'success');
+                      showToast(t('changePassword.success'), 'success');
                       setShowChangePasswordModal(false);
                       setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
                     } catch (error: unknown) {
-                      const message = error instanceof Error ? error.message : 'Failed to change password';
+                      const message = error instanceof Error ? error.message : t('changePassword.failed');
                       showToast(message, 'error');
                     } finally {
                       setChangePasswordLoading(false);
@@ -964,12 +963,12 @@ export function Layout() {
                   {changePasswordLoading ? (
                     <>
                       <Loader2 className="w-4 h-4 animate-spin" />
-                      Changing...
+                      {t('changePassword.changing')}
                     </>
                   ) : (
                     <>
                       <Key className="w-4 h-4" />
-                      Change Password
+                      {t('changePassword.title')}
                     </>
                   )}
                 </Button>

+ 16 - 8
frontend/src/components/LinkSpoolModal.tsx

@@ -1,8 +1,10 @@
 import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, Loader2, Link2, Check } from 'lucide-react';
 import { api } from '../api/client';
 import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
 
 interface LinkSpoolModalProps {
   isOpen: boolean;
@@ -16,7 +18,9 @@ interface LinkSpoolModalProps {
 }
 
 export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
+  const { showToast } = useToast();
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
 
   // Fetch unlinked spools
@@ -31,9 +35,14 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
     mutationFn: (spoolId: number) => api.linkSpool(spoolId, trayUuid),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
+      queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
+      showToast(t('spoolman.linkSuccess'), 'success');
       onClose();
     },
+    onError: (error: Error) => {
+      showToast(`${t('spoolman.linkFailed')}: ${error.message}`, 'error');
+    },
   });
 
   if (!isOpen) return null;
@@ -58,7 +67,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div className="flex items-center gap-2">
             <Link2 className="w-5 h-5 text-bambu-green" />
-            <h2 className="text-lg font-semibold text-white">Link to Spoolman</h2>
+            <h2 className="text-lg font-semibold text-white">{t('spoolman.linkToSpoolman')}</h2>
           </div>
           <button
             onClick={onClose}
@@ -89,14 +98,14 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
 
           {/* Spool UUID */}
           <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
-            <p className="text-xs text-bambu-gray mb-1">Spool UUID:</p>
+            <p className="text-xs text-bambu-gray mb-1">{t('spoolman.spoolId')}:</p>
             <code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
           </div>
 
           {/* Spool list */}
           <div>
             <p className="text-sm text-bambu-gray mb-2">
-              Select a Spoolman spool to link:
+              {t('spoolman.selectSpool')}:
             </p>
 
             {isLoading ? (
@@ -141,8 +150,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
               </div>
             ) : (
               <div className="text-center py-8 text-bambu-gray">
-                <p>No unlinked spools found in Spoolman.</p>
-                <p className="text-xs mt-1">All spools are already linked to AMS trays.</p>
+                <p>{t('spoolman.noUnlinkedSpools')}</p>
               </div>
             )}
           </div>
@@ -151,7 +159,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
         {/* Footer */}
         <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
           <Button variant="secondary" onClick={onClose}>
-            Cancel
+            {t('common.cancel')}
           </Button>
           <Button
             onClick={handleLink}
@@ -160,12 +168,12 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
             {linkMutation.isPending ? (
               <>
                 <Loader2 className="w-4 h-4 animate-spin" />
-                Linking...
+                {t('spoolman.syncing')}
               </>
             ) : (
               <>
                 <Link2 className="w-4 h-4" />
-                Link Spool
+                {t('spoolman.linkToSpoolman')}
               </>
             )}
           </Button>

+ 18 - 16
frontend/src/components/MQTTDebugModal.tsx

@@ -1,4 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp, Search } from 'lucide-react';
 import { api, type MQTTLogEntry } from '../api/client';
 import { Button } from './Button';
@@ -11,6 +12,7 @@ interface MQTTDebugModalProps {
 }
 
 export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const [autoScroll, setAutoScroll] = useState(true);
   const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
@@ -119,7 +121,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div>
-            <h2 className="text-lg font-semibold text-white">MQTT Debug Log</h2>
+            <h2 className="text-lg font-semibold text-white">{t('mqttDebug.title')}</h2>
             <p className="text-sm text-bambu-gray">{printerName}</p>
           </div>
           <button
@@ -141,7 +143,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                 disabled={disableMutation.isPending}
               >
                 <Square className="w-4 h-4" />
-                Stop
+                {t('mqttDebug.stopLogging')}
               </Button>
             ) : (
               <Button
@@ -150,7 +152,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                 disabled={enableMutation.isPending}
               >
                 <Play className="w-4 h-4" />
-                Start Logging
+                {t('mqttDebug.startLogging')}
               </Button>
             )}
             <Button
@@ -160,7 +162,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
               disabled={clearMutation.isPending || logs.length === 0}
             >
               <Trash2 className="w-4 h-4" />
-              Clear
+              {t('mqttDebug.clearLog')}
             </Button>
             <Button
               size="sm"
@@ -191,7 +193,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
               <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
               <input
                 type="text"
-                placeholder="Search topic or payload..."
+                placeholder={t('mqttDebug.searchPlaceholder')}
                 value={searchQuery}
                 onChange={(e) => setSearchQuery(e.target.value)}
                 className="w-full pl-8 pr-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
@@ -214,7 +216,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                     : 'text-bambu-gray hover:text-white'
                 }`}
               >
-                All
+                {t('mqttDebug.all')}
               </button>
               <button
                 onClick={() => setDirectionFilter('in')}
@@ -225,7 +227,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                 }`}
               >
                 <ArrowDown className="w-3 h-3" />
-                In
+                {t('mqttDebug.incoming')}
               </button>
               <button
                 onClick={() => setDirectionFilter('out')}
@@ -236,7 +238,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                 }`}
               >
                 <ArrowUp className="w-3 h-3" />
-                Out
+                {t('mqttDebug.outgoing')}
               </button>
             </div>
           </div>
@@ -249,15 +251,15 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
         >
           {logs.length === 0 ? (
             <div className="flex flex-col items-center justify-center h-full text-bambu-gray">
-              <p className="mb-2">No messages logged yet</p>
+              <p className="mb-2">{t('mqttDebug.noMessages')}</p>
               {!loggingEnabled && (
-                <p className="text-sm">Click "Start Logging" to begin capturing MQTT messages</p>
+                <p className="text-sm">{t('mqttDebug.startLoggingHint')}</p>
               )}
             </div>
           ) : filteredLogs.length === 0 ? (
             <div className="flex flex-col items-center justify-center h-full text-bambu-gray">
-              <p className="mb-2">No messages match your filter</p>
-              <p className="text-sm">Try adjusting your search or filter criteria</p>
+              <p className="mb-2">{t('mqttDebug.noMessagesMatch')}</p>
+              <p className="text-sm">{t('mqttDebug.adjustFilterHint')}</p>
             </div>
           ) : (
             <div className="space-y-1">
@@ -281,7 +283,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                         className={`shrink-0 ${
                           isIncoming ? 'text-blue-400' : 'text-green-400'
                         }`}
-                        title={isIncoming ? 'Incoming' : 'Outgoing'}
+                        title={isIncoming ? t('mqttDebug.incoming') : t('mqttDebug.outgoing')}
                       >
                         {isIncoming ? (
                           <ArrowDown className="w-3 h-3" />
@@ -313,14 +315,14 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
             {loggingEnabled ? (
               <span className="flex items-center gap-2">
                 <span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
-                Logging active - messages will auto-refresh
+                {t('mqttDebug.loggingActive')}
               </span>
             ) : (
-              <span>Logging stopped</span>
+              <span>{t('mqttDebug.loggingStopped')}</span>
             )}
           </div>
           <Button variant="secondary" onClick={onClose}>
-            Close
+            {t('common.close')}
           </Button>
         </div>
       </div>

+ 69 - 5
frontend/src/components/PrintModal/PrinterSelector.tsx

@@ -38,6 +38,10 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
   targetModel?: string | null;
   /** Handler for target model change */
   onTargetModelChange?: (model: string | null) => void;
+  /** Selected target location (when assignmentMode is 'model') */
+  targetLocation?: string | null;
+  /** Handler for target location change */
+  onTargetLocationChange?: (location: string | null) => void;
   /** Suggested model from sliced file (for pre-selection) */
   slicedForModel?: string | null;
 }
@@ -227,6 +231,8 @@ export function PrinterSelector({
   onAssignmentModeChange,
   targetModel,
   onTargetModelChange,
+  targetLocation,
+  onTargetLocationChange,
   slicedForModel,
 }: PrinterSelectorWithMappingProps) {
   // State for showing all printers vs only matching model
@@ -257,6 +263,16 @@ export function PrinterSelector({
     return [...new Set(models)].sort();
   }, [activePrinters]);
 
+  // Get unique locations for the selected target model (for location filtering)
+  const uniqueLocations = useMemo(() => {
+    if (!targetModel) return [];
+    const locations = activePrinters
+      .filter(p => p.model === targetModel && p.location)
+      .map(p => p.location)
+      .filter((l): l is string => Boolean(l));
+    return [...new Set(locations)].sort();
+  }, [activePrinters, targetModel]);
+
   // Check if model-based assignment is available (need callbacks and multiple printers of same model)
   const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
 
@@ -370,11 +386,59 @@ export function PrinterSelector({
         </div>
       )}
 
-      {/* Model info (when in model mode) */}
-      {assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
-        <p className="text-xs text-bambu-gray mb-4">
-          Scheduler will assign to first available idle {targetModel} printer
-        </p>
+      {/* Model selection and location filter (when in model mode) */}
+      {assignmentMode === 'model' && modelAssignmentAvailable && (
+        <div className="space-y-3 mb-4">
+          {/* Model selector */}
+          <div>
+            <label className="block text-xs text-bambu-gray mb-1">Target Model</label>
+            <select
+              value={targetModel || ''}
+              onChange={(e) => {
+                onTargetModelChange!(e.target.value || null);
+                // Clear location when model changes
+                if (onTargetLocationChange) {
+                  onTargetLocationChange(null);
+                }
+              }}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+            >
+              <option value="">Select a model...</option>
+              {uniqueModels.map((model) => (
+                <option key={model} value={model}>
+                  {model}
+                </option>
+              ))}
+            </select>
+          </div>
+
+          {/* Location filter (only show when target model is selected and locations exist) */}
+          {targetModel && uniqueLocations.length > 0 && onTargetLocationChange && (
+            <div>
+              <label className="block text-xs text-bambu-gray mb-1">Location Filter (optional)</label>
+              <select
+                value={targetLocation || ''}
+                onChange={(e) => onTargetLocationChange(e.target.value || null)}
+                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
+              >
+                <option value="">Any location</option>
+                {uniqueLocations.map((location) => (
+                  <option key={location} value={location}>
+                    {location}
+                  </option>
+                ))}
+              </select>
+            </div>
+          )}
+
+          {/* Info text */}
+          {targetModel && (
+            <p className="text-xs text-bambu-gray">
+              Scheduler will assign to first available idle {targetModel} printer
+              {targetLocation ? ` in ${targetLocation}` : ''}
+            </p>
+          )}
+        </div>
       )}
 
       {/* Multi-select header (only in printer mode) */}

+ 150 - 6
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -1,17 +1,117 @@
+import { useState, useEffect, useRef } from 'react';
 import { Calendar, Clock, Hand, Power } from 'lucide-react';
-import { getMinDateTime } from '../../utils/amsHelpers';
 import type { ScheduleOptionsProps, ScheduleType } from './types';
+import {
+  formatDateInput,
+  formatTimeInput,
+  parseDateInput,
+  parseTimeInput,
+  getDatePlaceholder,
+  getTimePlaceholder,
+  toDateTimeLocalValue,
+  type DateFormat,
+  type TimeFormat,
+} from '../../utils/date';
 
 /**
  * Schedule options component for queue items.
  * Includes schedule type (ASAP/Scheduled/Queue Only), datetime picker,
  * and options for require previous success and auto power off.
  */
-export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps) {
+export function ScheduleOptionsPanel({
+  options,
+  onChange,
+  dateFormat = 'system',
+  timeFormat = 'system',
+}: ScheduleOptionsProps) {
+  const [dateValue, setDateValue] = useState('');
+  const [timeValue, setTimeValue] = useState('');
+  const [isDateValid, setIsDateValid] = useState(true);
+  const [isTimeValid, setIsTimeValid] = useState(true);
+  const hiddenInputRef = useRef<HTMLInputElement>(null);
+  const isInitializedRef = useRef(false);
+
+  // Initialize or sync from options.scheduledTime
+  useEffect(() => {
+    if (options.scheduleType !== 'scheduled') {
+      isInitializedRef.current = false;
+      return;
+    }
+
+    // Initialize with default time (now + 1 hour) or from existing value
+    if (!isInitializedRef.current) {
+      isInitializedRef.current = true;
+      let date: Date;
+
+      if (options.scheduledTime) {
+        date = new Date(options.scheduledTime);
+        if (isNaN(date.getTime())) {
+          date = new Date();
+          date.setHours(date.getHours() + 1, 0, 0, 0);
+        }
+      } else {
+        date = new Date();
+        date.setHours(date.getHours() + 1, 0, 0, 0);
+        // Set initial value
+        onChange({ ...options, scheduledTime: toDateTimeLocalValue(date) });
+      }
+
+      setDateValue(formatDateInput(date, dateFormat as DateFormat));
+      setTimeValue(formatTimeInput(date, timeFormat as TimeFormat));
+      setIsDateValid(true);
+      setIsTimeValid(true);
+    }
+  }, [options.scheduleType, options.scheduledTime, dateFormat, timeFormat, onChange, options]);
+
   const handleScheduleTypeChange = (scheduleType: ScheduleType) => {
     onChange({ ...options, scheduleType });
   };
 
+  const updateScheduledTime = (newDateValue: string, newTimeValue: string) => {
+    const parsedDate = parseDateInput(newDateValue, dateFormat as DateFormat);
+    const parsedTime = parseTimeInput(newTimeValue);
+
+    setIsDateValid(!!parsedDate);
+    setIsTimeValid(!!parsedTime);
+
+    if (parsedDate && parsedTime) {
+      parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0);
+      const now = new Date();
+      if (parsedDate > now) {
+        onChange({ ...options, scheduledTime: toDateTimeLocalValue(parsedDate) });
+      }
+    }
+  };
+
+  const handleDateChange = (value: string) => {
+    setDateValue(value);
+    updateScheduledTime(value, timeValue);
+  };
+
+  const handleTimeChange = (value: string) => {
+    setTimeValue(value);
+    updateScheduledTime(dateValue, value);
+  };
+
+  // Handle calendar picker selection
+  const handleCalendarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const value = e.target.value;
+    if (value) {
+      const date = new Date(value);
+      if (!isNaN(date.getTime())) {
+        setDateValue(formatDateInput(date, dateFormat as DateFormat));
+        setTimeValue(formatTimeInput(date, timeFormat as TimeFormat));
+        setIsDateValid(true);
+        setIsTimeValid(true);
+        onChange({ ...options, scheduledTime: value });
+      }
+    }
+  };
+
+  const openCalendar = () => {
+    hiddenInputRef.current?.showPicker();
+  };
+
   return (
     <div className="space-y-4">
       {/* Schedule type */}
@@ -61,14 +161,58 @@ export function ScheduleOptionsPanel({ options, onChange }: ScheduleOptionsProps
       {options.scheduleType === 'scheduled' && (
         <div>
           <label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
+          <div className="flex gap-2">
+            {/* Date input */}
+            <div className="flex-1 relative">
+              <input
+                type="text"
+                className={`w-full px-3 py-2 pr-10 bg-bambu-dark border rounded-lg text-white focus:outline-none ${
+                  isDateValid
+                    ? 'border-bambu-dark-tertiary focus:border-bambu-green'
+                    : 'border-red-500'
+                }`}
+                value={dateValue}
+                onChange={(e) => handleDateChange(e.target.value)}
+                placeholder={getDatePlaceholder(dateFormat as DateFormat)}
+              />
+              <button
+                type="button"
+                onClick={openCalendar}
+                className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                title="Open calendar"
+              >
+                <Calendar className="w-4 h-4" />
+              </button>
+            </div>
+            {/* Time input */}
+            <div className="w-32">
+              <input
+                type="text"
+                className={`w-full px-3 py-2 bg-bambu-dark border rounded-lg text-white focus:outline-none ${
+                  isTimeValid
+                    ? 'border-bambu-dark-tertiary focus:border-bambu-green'
+                    : 'border-red-500'
+                }`}
+                value={timeValue}
+                onChange={(e) => handleTimeChange(e.target.value)}
+                placeholder={getTimePlaceholder(timeFormat as TimeFormat)}
+              />
+            </div>
+          </div>
+          {/* Hidden datetime-local for calendar picker */}
           <input
+            ref={hiddenInputRef}
             type="datetime-local"
-            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"
+            className="sr-only"
             value={options.scheduledTime}
-            onChange={(e) => onChange({ ...options, scheduledTime: e.target.value })}
-            min={getMinDateTime()}
-            required
+            onChange={handleCalendarChange}
+            tabIndex={-1}
           />
+          {(!isDateValid || !isTimeValid) && (
+            <p className="mt-1 text-xs text-red-400">
+              Please enter a valid date and time
+            </p>
+          )}
         </div>
       )}
 

+ 33 - 13
frontend/src/components/PrintModal/index.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
 import { api } from '../../api/client';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
@@ -41,6 +42,7 @@ export function PrintModal({
   onClose,
   onSuccess,
 }: PrintModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
@@ -135,6 +137,14 @@ export function PrintModal({
     return null;
   });
 
+  // Target location for model-based assignment (optional filter)
+  const [targetLocation, setTargetLocation] = useState<string | null>(() => {
+    if (mode === 'edit-queue-item' && queueItem?.target_location) {
+      return queueItem.target_location;
+    }
+    return null;
+  });
+
   // Track initial values for clearing mappings on change (edit mode only)
   const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
   const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@@ -369,6 +379,7 @@ export function PrintModal({
     const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
       printer_id: assignmentMode === 'printer' ? printerId : null,
       target_model: assignmentMode === 'model' ? targetModel : null,
+      target_location: assignmentMode === 'model' ? targetLocation : null,
       // Use library_file_id for library files, archive_id for archives
       archive_id: isLibraryFile ? undefined : archiveId,
       library_file_id: isLibraryFile ? libraryFileId : undefined,
@@ -397,6 +408,7 @@ export function PrintModal({
           const updateData: PrintQueueItemUpdate = {
             printer_id: null,
             target_model: targetModel,
+            target_location: targetLocation,
             require_previous_success: scheduleOptions.requirePreviousSuccess,
             auto_off_after: scheduleOptions.autoOffAfter,
             manual_start: scheduleOptions.scheduleType === 'manual',
@@ -445,6 +457,7 @@ export function PrintModal({
             const updateData: PrintQueueItemUpdate = {
               printer_id: printerId,
               target_model: null,
+              target_location: null,
               require_previous_success: scheduleOptions.requirePreviousSuccess,
               auto_off_after: scheduleOptions.autoOffAfter,
               manual_start: scheduleOptions.scheduleType === 'manual',
@@ -518,35 +531,35 @@ export function PrintModal({
 
     if (mode === 'reprint') {
       return {
-        title: isLibraryFile ? 'Print' : 'Re-print',
+        title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
         icon: Printer,
-        submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
+        submitText: printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
         submitIcon: Printer,
         loadingText: submitProgress.total > 1
-          ? `Sending ${submitProgress.current}/${submitProgress.total}...`
-          : 'Sending...',
+          ? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
+          : t('queue.sending'),
       };
     }
     if (mode === 'add-to-queue') {
       return {
-        title: 'Schedule Print',
+        title: t('queue.schedulePrint'),
         icon: Calendar,
-        submitText: printerCount > 1 ? `Queue to ${printerCount} Printers` : 'Add to Queue',
+        submitText: printerCount > 1 ? t('queue.queueToPrinters', { count: printerCount }) : t('queue.addToQueue'),
         submitIcon: Calendar,
         loadingText: submitProgress.total > 1
-          ? `Adding ${submitProgress.current}/${submitProgress.total}...`
-          : 'Adding...',
+          ? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
+          : t('queue.adding'),
       };
     }
     // edit-queue-item mode
     return {
-      title: 'Edit Queue Item',
+      title: t('queue.editQueueItem'),
       icon: Pencil,
-      submitText: 'Save',
+      submitText: t('common.save'),
       submitIcon: Pencil,
       loadingText: submitProgress.total > 1
-        ? `Saving ${submitProgress.current}/${submitProgress.total}...`
-        : 'Saving...',
+        ? t('queue.savingProgress', { current: submitProgress.current, total: submitProgress.total })
+        : t('common.saving'),
     };
   };
 
@@ -626,6 +639,8 @@ export function PrintModal({
               onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
               targetModel={targetModel}
               onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
+              targetLocation={targetLocation}
+              onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
               slicedForModel={slicedForModel}
             />
 
@@ -673,7 +688,12 @@ export function PrintModal({
 
             {/* Schedule options - only for queue modes */}
             {mode !== 'reprint' && (
-              <ScheduleOptionsPanel options={scheduleOptions} onChange={setScheduleOptions} />
+              <ScheduleOptionsPanel
+                options={scheduleOptions}
+                onChange={setScheduleOptions}
+                dateFormat={settings?.date_format || 'system'}
+                timeFormat={settings?.time_format || 'system'}
+              />
             )}
 
             {/* Error message */}

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

@@ -130,6 +130,10 @@ export interface PrinterSelectorProps {
   targetModel?: string | null;
   /** Handler for target model change */
   onTargetModelChange?: (model: string | null) => void;
+  /** Selected target location (when assignmentMode is 'model') */
+  targetLocation?: string | null;
+  /** Handler for target location change */
+  onTargetLocationChange?: (location: string | null) => void;
   /** Suggested model from sliced file (for pre-selection) */
   slicedForModel?: string | null;
 }
@@ -183,4 +187,8 @@ export interface PrintOptionsProps {
 export interface ScheduleOptionsProps {
   options: ScheduleOptions;
   onChange: (options: ScheduleOptions) => void;
+  /** Date format setting from user preferences */
+  dateFormat?: 'system' | 'us' | 'eu' | 'iso';
+  /** Time format setting from user preferences */
+  timeFormat?: 'system' | '12h' | '24h';
 }

+ 5 - 0
frontend/src/components/SmartPlugCard.tsx

@@ -79,6 +79,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
       // Also invalidate printer-specific smart plug queries to keep PrintersPage in sync
       if (plug.printer_id) {
         queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] });
+        queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
       }
     },
   });
@@ -88,6 +89,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
     mutationFn: () => api.deleteSmartPlug(plug.id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });
+      // Also invalidate printer card HA entity queries
+      if (plug.printer_id) {
+        queryClient.invalidateQueries({ queryKey: ['scriptPlugsByPrinter', plug.printer_id] });
+      }
     },
   });
 

+ 2 - 28
frontend/src/components/SpoolmanSettings.tsx

@@ -6,32 +6,6 @@ import type { SpoolmanSyncResult, Printer } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 
-interface SpoolmanSettingsData {
-  spoolman_enabled: string;
-  spoolman_url: string;
-  spoolman_sync_mode: string;
-}
-
-async function getSpoolmanSettings(): Promise<SpoolmanSettingsData> {
-  const response = await fetch('/api/v1/settings/spoolman');
-  if (!response.ok) {
-    throw new Error('Failed to load Spoolman settings');
-  }
-  return response.json();
-}
-
-async function updateSpoolmanSettings(data: Partial<SpoolmanSettingsData>): Promise<SpoolmanSettingsData> {
-  const response = await fetch('/api/v1/settings/spoolman', {
-    method: 'PUT',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify(data),
-  });
-  if (!response.ok) {
-    throw new Error('Failed to save Spoolman settings');
-  }
-  return response.json();
-}
-
 export function SpoolmanSettings() {
   const queryClient = useQueryClient();
   const [localEnabled, setLocalEnabled] = useState(false);
@@ -45,7 +19,7 @@ export function SpoolmanSettings() {
   // Fetch Spoolman settings
   const { data: settings, isLoading: settingsLoading } = useQuery({
     queryKey: ['spoolman-settings'],
-    queryFn: getSpoolmanSettings,
+    queryFn: api.getSpoolmanSettings,
   });
 
   // Fetch Spoolman status
@@ -93,7 +67,7 @@ export function SpoolmanSettings() {
   // Save mutation
   const saveMutation = useMutation({
     mutationFn: () =>
-      updateSpoolmanSettings({
+      api.updateSpoolmanSettings({
         spoolman_enabled: localEnabled ? 'true' : 'false',
         spoolman_url: localUrl,
         spoolman_sync_mode: localSyncMode,

+ 12 - 10
frontend/src/components/UploadModal.tsx

@@ -1,5 +1,6 @@
 import { useState, useCallback, useRef, useEffect } from 'react';
 import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
 import { api } from '../api/client';
 import type { BulkUploadResult } from '../api/client';
@@ -20,6 +21,7 @@ interface UploadModalProps {
 }
 
 export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const fileInputRef = useRef<HTMLInputElement>(null);
@@ -150,7 +152,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
         <CardContent className="p-0 flex flex-col h-full">
           {/* Header */}
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-            <h2 className="text-xl font-semibold text-white">Upload 3MF Files</h2>
+            <h2 className="text-xl font-semibold text-white">{t('uploadModal.title')}</h2>
             <button
               onClick={onClose}
               className="text-bambu-gray hover:text-white transition-colors"
@@ -173,15 +175,15 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
             >
               <Upload className="w-12 h-12 mx-auto mb-4 text-bambu-gray" />
               <p className="text-white mb-2">
-                Drag & drop .3mf files here
+                {t('uploadModal.dragDrop')}
               </p>
-              <p className="text-bambu-gray text-sm mb-4">or</p>
+              <p className="text-bambu-gray text-sm mb-4">{t('uploadModal.or')}</p>
               <Button
                 variant="secondary"
                 onClick={() => fileInputRef.current?.click()}
                 disabled={isUploading}
               >
-                Browse Files
+                {t('uploadModal.browseFiles')}
               </Button>
               <input
                 ref={fileInputRef}
@@ -197,7 +199,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
           {/* Info about printer model extraction */}
           <div className="px-4 pb-4">
             <p className="text-xs text-bambu-gray">
-              The printer model will be automatically extracted from the 3MF file metadata.
+              {t('uploadModal.extractionInfo')}
             </p>
           </div>
 
@@ -249,9 +251,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
             <div className="px-4 pb-4">
               <div className="p-3 bg-bambu-dark rounded-lg">
                 <p className="text-sm text-white">
-                  <span className="text-bambu-green">{uploadResult.uploaded}</span> uploaded
+                  <span className="text-bambu-green">{uploadResult.uploaded}</span> {t('uploadModal.uploaded')}
                   {uploadResult.failed > 0 && (
-                    <>, <span className="text-red-400">{uploadResult.failed}</span> failed</>
+                    <>, <span className="text-red-400">{uploadResult.failed}</span> {t('uploadModal.failed')}</>
                   )}
                 </p>
               </div>
@@ -261,7 +263,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
           {/* Footer */}
           <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
             <Button variant="secondary" onClick={onClose} className="flex-1">
-              {uploadResult ? 'Close' : 'Cancel'}
+              {uploadResult ? t('common.close') : t('common.cancel')}
             </Button>
             {!uploadResult && (
               <Button
@@ -272,12 +274,12 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
                 {isUploading ? (
                   <>
                     <Loader2 className="w-4 h-4 animate-spin" />
-                    Uploading...
+                    {t('uploadModal.uploading')}
                   </>
                 ) : (
                   <>
                     <Upload className="w-4 h-4" />
-                    Upload {pendingCount > 0 && `(${pendingCount})`}
+                    {t('uploadModal.upload')} {pendingCount > 0 && `(${pendingCount})`}
                   </>
                 )}
               </Button>

+ 267 - 116
frontend/src/components/VirtualPrinterSettings.tsx

@@ -1,21 +1,26 @@
 import { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown, ExternalLink } from 'lucide-react';
-import { virtualPrinterApi } from '../api/client';
+import { Loader2, Check, AlertTriangle, Printer, Eye, EyeOff, Info, ChevronDown, ExternalLink, ArrowRightLeft } from 'lucide-react';
+import { api, virtualPrinterApi } from '../api/client';
 import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 
+type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
+
 export function VirtualPrinterSettings() {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
   const [localEnabled, setLocalEnabled] = useState(false);
   const [localAccessCode, setLocalAccessCode] = useState('');
-  const [localMode, setLocalMode] = useState<'immediate' | 'review' | 'print_queue'>('immediate');
+  const [localMode, setLocalMode] = useState<LocalMode>('immediate');
   const [localModel, setLocalModel] = useState('3DPrinter-X1-Carbon');
+  const [localTargetPrinterId, setLocalTargetPrinterId] = useState<number | null>(null);
   const [showAccessCode, setShowAccessCode] = useState(false);
-  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | null>(null);
+  const [pendingAction, setPendingAction] = useState<'toggle' | 'accessCode' | 'mode' | 'model' | 'targetPrinter' | null>(null);
 
   // Fetch current settings
   const { data: settings, isLoading } = useQuery({
@@ -30,38 +35,46 @@ export function VirtualPrinterSettings() {
     queryFn: virtualPrinterApi.getModels,
   });
 
+  // Fetch printers for proxy mode dropdown
+  const { data: printers } = useQuery({
+    queryKey: ['printers'],
+    queryFn: api.getPrinters,
+  });
+
   // Initialize local state from settings
   useEffect(() => {
     if (settings) {
       setLocalEnabled(settings.enabled);
       // Map legacy 'queue' mode to 'review'
-      let mode: 'immediate' | 'review' | 'print_queue' = settings.mode === 'queue' ? 'review' : settings.mode;
-      if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue') {
+      let mode: LocalMode = settings.mode === 'queue' ? 'review' : settings.mode as LocalMode;
+      if (mode !== 'immediate' && mode !== 'review' && mode !== 'print_queue' && mode !== 'proxy') {
         mode = 'immediate'; // fallback
       }
       setLocalMode(mode);
       setLocalModel(settings.model);
+      setLocalTargetPrinterId(settings.target_printer_id);
     }
   }, [settings]);
 
   // Update mutation
   const updateMutation = useMutation({
-    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: 'immediate' | 'review' | 'print_queue'; model?: string }) =>
+    mutationFn: (data: { enabled?: boolean; access_code?: string; mode?: LocalMode; model?: string; target_printer_id?: number }) =>
       virtualPrinterApi.updateSettings(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['virtual-printer-settings'] });
-      showToast('Virtual printer settings updated');
+      showToast(t('virtualPrinter.toast.updated'));
       setPendingAction(null);
     },
     onError: (error: Error) => {
-      showToast(error.message || 'Failed to update settings', 'error');
+      showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
       // Revert local state on error
       if (settings) {
         setLocalEnabled(settings.enabled);
         // Map legacy 'queue' mode to 'review'
         const mode = settings.mode === 'queue' ? 'review' : settings.mode;
-        setLocalMode(mode === 'print_queue' || mode === 'review' ? mode : 'immediate');
+        setLocalMode(['immediate', 'review', 'print_queue', 'proxy'].includes(mode) ? mode as LocalMode : 'immediate');
         setLocalModel(settings.model);
+        setLocalTargetPrinterId(settings.target_printer_id);
       }
       setPendingAction(null);
     },
@@ -70,29 +83,41 @@ export function VirtualPrinterSettings() {
   const handleToggleEnabled = () => {
     const newEnabled = !localEnabled;
 
-    // If enabling, must have access code
-    if (newEnabled && !localAccessCode && !settings?.access_code_set) {
-      showToast('Please set an access code first', 'error');
-      return;
+    // Validation depends on mode
+    if (newEnabled) {
+      if (localMode === 'proxy') {
+        // Proxy mode requires target printer
+        if (!localTargetPrinterId) {
+          showToast(t('virtualPrinter.toast.targetPrinterRequired'), 'error');
+          return;
+        }
+      } else {
+        // Other modes require access code
+        if (!localAccessCode && !settings?.access_code_set) {
+          showToast(t('virtualPrinter.toast.accessCodeRequired'), 'error');
+          return;
+        }
+      }
     }
 
     setLocalEnabled(newEnabled);
     setPendingAction('toggle');
     updateMutation.mutate({
       enabled: newEnabled,
-      access_code: localAccessCode || undefined,
+      access_code: localMode !== 'proxy' ? (localAccessCode || undefined) : undefined,
       mode: localMode,
+      target_printer_id: localMode === 'proxy' ? (localTargetPrinterId ?? undefined) : undefined,
     });
   };
 
   const handleAccessCodeChange = () => {
     if (!localAccessCode) {
-      showToast('Access code cannot be empty', 'error');
+      showToast(t('virtualPrinter.toast.accessCodeEmpty'), 'error');
       return;
     }
 
     if (localAccessCode.length !== 8) {
-      showToast('Access code must be exactly 8 characters', 'error');
+      showToast(t('virtualPrinter.toast.accessCodeLength'), 'error');
       return;
     }
 
@@ -103,12 +128,20 @@ export function VirtualPrinterSettings() {
     setLocalAccessCode(''); // Clear after saving
   };
 
-  const handleModeChange = (mode: 'immediate' | 'review' | 'print_queue') => {
+  const handleModeChange = (mode: LocalMode) => {
     setLocalMode(mode);
     setPendingAction('mode');
     updateMutation.mutate({ mode });
   };
 
+  const handleTargetPrinterChange = (printerId: number) => {
+    setLocalTargetPrinterId(printerId);
+    setPendingAction('targetPrinter');
+    updateMutation.mutate({
+      target_printer_id: printerId,
+    });
+  };
+
   const handleModelChange = (model: string) => {
     setLocalModel(model);
     setPendingAction('model');
@@ -137,28 +170,33 @@ export function VirtualPrinterSettings() {
           <div className="flex items-center justify-between">
             <div className="flex items-center gap-2">
               <Printer className="w-5 h-5 text-bambu-green" />
-              <h2 className="text-lg font-semibold text-white">Virtual Printer</h2>
+              <h2 className="text-lg font-semibold text-white">{t('virtualPrinter.title')}</h2>
             </div>
             {status && (
               <div className={`flex items-center gap-2 text-sm ${isRunning ? 'text-green-400' : 'text-bambu-gray'}`}>
                 <span className={`w-2 h-2 rounded-full ${isRunning ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
-                {isRunning ? 'Running' : 'Stopped'}
+                {isRunning ? t('virtualPrinter.running') : t('virtualPrinter.stopped')}
               </div>
             )}
           </div>
         </CardHeader>
         <CardContent className="space-y-4">
           <p className="text-sm text-bambu-gray">
-            Enable a virtual printer that appears in Bambu Studio and OrcaSlicer. Files sent to this printer
-            will be archived directly without printing.
+            {localMode === 'proxy'
+              ? t('virtualPrinter.description.proxy')
+              : t('virtualPrinter.description.default')}
           </p>
 
           {/* Enable/Disable Toggle */}
           <div className="flex items-center justify-between py-3 border-t border-bambu-dark-tertiary">
             <div>
-              <div className="text-white font-medium">Enable Virtual Printer</div>
+              <div className="text-white font-medium">{t('virtualPrinter.enable.title')}</div>
               <div className="text-sm text-bambu-gray">
-                {isRunning ? 'Visible as "Bambuddy" in slicer discovery' : 'Not visible to slicers'}
+                {isRunning ? (
+                  localMode === 'proxy'
+                    ? t('virtualPrinter.enable.proxyingTo', { name: printers?.find(p => p.id === localTargetPrinterId)?.name || 'printer' })
+                    : t('virtualPrinter.enable.visibleInSlicer')
+                ) : t('virtualPrinter.enable.notActive')}
               </div>
             </div>
             <button
@@ -176,11 +214,12 @@ export function VirtualPrinterSettings() {
             </button>
           </div>
 
-          {/* Printer Model */}
+          {/* Printer Model - only for non-proxy modes */}
+          {localMode !== 'proxy' && (
           <div className="py-3 border-t border-bambu-dark-tertiary">
-            <div className="text-white font-medium mb-2">Printer Model</div>
+            <div className="text-white font-medium mb-2">{t('virtualPrinter.model.title')}</div>
             <div className="text-sm text-bambu-gray mb-3">
-              Select which printer model to emulate.
+              {t('virtualPrinter.model.description')}
             </div>
             <div className="relative">
               <select
@@ -202,66 +241,119 @@ export function VirtualPrinterSettings() {
             {localEnabled && isRunning && (
               <p className="text-xs text-bambu-gray mt-2">
                 <Info className="w-3 h-3 inline mr-1" />
-                Changing the model will restart the virtual printer
+                {t('virtualPrinter.model.restartWarning')}
               </p>
             )}
           </div>
+          )}
 
-          {/* Access Code */}
-          <div className="py-3 border-t border-bambu-dark-tertiary">
-            <div className="text-white font-medium mb-2">Access Code</div>
-            <div className="text-sm text-bambu-gray mb-3">
-              {settings?.access_code_set ? (
-                <span className="flex items-center gap-1 text-green-400">
-                  <Check className="w-4 h-4" />
-                  Access code is set
-                </span>
-              ) : (
-                <span className="flex items-center gap-1 text-yellow-400">
-                  <AlertTriangle className="w-4 h-4" />
-                  No access code set - required to enable
-                </span>
-              )}
-            </div>
-            <div className="flex gap-2">
-              <div className="relative flex-1">
-                <input
-                  type={showAccessCode ? 'text' : 'password'}
-                  value={localAccessCode}
-                  onChange={(e) => setLocalAccessCode(e.target.value)}
-                  placeholder={settings?.access_code_set ? 'Enter new code to change' : 'Enter 8-char code'}
-                  maxLength={8}
-                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray pr-10 font-mono"
-                />
-                <button
-                  onClick={() => setShowAccessCode(!showAccessCode)}
-                  className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+          {/* Access Code - only for non-proxy modes */}
+          {localMode !== 'proxy' && (
+            <div className="py-3 border-t border-bambu-dark-tertiary">
+              <div className="text-white font-medium mb-2">{t('virtualPrinter.accessCode.title')}</div>
+              <div className="text-sm text-bambu-gray mb-3">
+                {settings?.access_code_set ? (
+                  <span className="flex items-center gap-1 text-green-400">
+                    <Check className="w-4 h-4" />
+                    {t('virtualPrinter.accessCode.isSet')}
+                  </span>
+                ) : (
+                  <span className="flex items-center gap-1 text-yellow-400">
+                    <AlertTriangle className="w-4 h-4" />
+                    {t('virtualPrinter.accessCode.notSet')}
+                  </span>
+                )}
+              </div>
+              <div className="flex gap-2">
+                <div className="relative flex-1">
+                  <input
+                    type={showAccessCode ? 'text' : 'password'}
+                    value={localAccessCode}
+                    onChange={(e) => setLocalAccessCode(e.target.value)}
+                    placeholder={settings?.access_code_set ? t('virtualPrinter.accessCode.placeholderChange') : t('virtualPrinter.accessCode.placeholder')}
+                    maxLength={8}
+                    className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white placeholder-bambu-gray pr-10 font-mono"
+                  />
+                  <button
+                    onClick={() => setShowAccessCode(!showAccessCode)}
+                    className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                  >
+                    {showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
+                  </button>
+                </div>
+                <Button
+                  onClick={handleAccessCodeChange}
+                  disabled={!localAccessCode || pendingAction === 'accessCode'}
+                  variant="primary"
                 >
-                  {showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
-                </button>
+                  {pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.save')}
+                </Button>
               </div>
-              <Button
-                onClick={handleAccessCodeChange}
-                disabled={!localAccessCode || pendingAction === 'accessCode'}
-                variant="primary"
-              >
-                {pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save'}
-              </Button>
+              <p className="text-xs text-bambu-gray mt-2">
+                {t('virtualPrinter.accessCode.hint')}
+                {localAccessCode && (
+                  <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
+                    {' '}{t('virtualPrinter.accessCode.charCount', { count: localAccessCode.length })}
+                  </span>
+                )}
+              </p>
             </div>
-            <p className="text-xs text-bambu-gray mt-2">
-              Must be exactly 8 characters. Used by slicers to authenticate.
-              {localAccessCode && (
-                <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
-                  {' '}({localAccessCode.length}/8)
-                </span>
+          )}
+
+          {/* Target Printer - only for proxy mode */}
+          {localMode === 'proxy' && (
+            <div className="py-3 border-t border-bambu-dark-tertiary">
+              <div className="text-white font-medium mb-2">{t('virtualPrinter.targetPrinter.title')}</div>
+              <div className="text-sm text-bambu-gray mb-3">
+                {localTargetPrinterId ? (
+                  <span className="flex items-center gap-1 text-green-400">
+                    <Check className="w-4 h-4" />
+                    {t('virtualPrinter.targetPrinter.configured')}
+                  </span>
+                ) : (
+                  <span className="flex items-center gap-1 text-yellow-400">
+                    <AlertTriangle className="w-4 h-4" />
+                    {t('virtualPrinter.targetPrinter.notConfigured')}
+                  </span>
+                )}
+              </div>
+              <div className="relative">
+                <select
+                  value={localTargetPrinterId ?? ''}
+                  onChange={(e) => {
+                    const id = parseInt(e.target.value, 10);
+                    if (!isNaN(id)) {
+                      handleTargetPrinterChange(id);
+                    }
+                  }}
+                  disabled={pendingAction === 'targetPrinter'}
+                  className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-2 text-white appearance-none cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed pr-10"
+                >
+                  <option value="">{t('virtualPrinter.targetPrinter.placeholder')}</option>
+                  {printers?.map((printer) => (
+                    <option key={printer.id} value={printer.id}>
+                      {printer.name} ({printer.ip_address})
+                    </option>
+                  ))}
+                </select>
+                <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
+              </div>
+              <p className="text-xs text-bambu-gray mt-2">
+                {t('virtualPrinter.targetPrinter.hint')}
+              </p>
+              {!printers?.length && (
+                <p className="text-xs text-yellow-400 mt-2">
+                  <AlertTriangle className="w-3 h-3 inline mr-1" />
+                  {t('virtualPrinter.targetPrinter.noPrinters')}
+                </p>
               )}
-            </p>
-          </div>
+            </div>
+          )}
 
           {/* Mode */}
           <div className="py-3 border-t border-bambu-dark-tertiary">
-            <div className="text-white font-medium mb-2">Mode</div>
-            <div className="grid grid-cols-3 gap-3">
+            <div className="text-white font-medium mb-2">{t('virtualPrinter.mode.title')}</div>
+            <div className="grid grid-cols-2 gap-3">
               <button
                 onClick={() => handleModeChange('immediate')}
                 disabled={pendingAction === 'mode'}
@@ -271,8 +363,8 @@ export function VirtualPrinterSettings() {
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
               >
-                <div className="text-white font-medium">Archive</div>
-                <div className="text-xs text-bambu-gray">Archive files immediately</div>
+                <div className="text-white font-medium">{t('virtualPrinter.mode.archive')}</div>
+                <div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.archiveDesc')}</div>
               </button>
               <button
                 onClick={() => handleModeChange('review')}
@@ -283,8 +375,8 @@ export function VirtualPrinterSettings() {
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
               >
-                <div className="text-white font-medium">Review</div>
-                <div className="text-xs text-bambu-gray">Review and tag before archiving</div>
+                <div className="text-white font-medium">{t('virtualPrinter.mode.review')}</div>
+                <div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.reviewDesc')}</div>
               </button>
               <button
                 onClick={() => handleModeChange('print_queue')}
@@ -295,8 +387,23 @@ export function VirtualPrinterSettings() {
                     : 'border-bambu-dark-tertiary hover:border-bambu-gray'
                 }`}
               >
-                <div className="text-white font-medium">Queue</div>
-                <div className="text-xs text-bambu-gray">Archive and add to print queue</div>
+                <div className="text-white font-medium">{t('virtualPrinter.mode.queue')}</div>
+                <div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.queueDesc')}</div>
+              </button>
+              <button
+                onClick={() => handleModeChange('proxy')}
+                disabled={pendingAction === 'mode'}
+                className={`p-3 rounded-lg border text-left transition-colors ${
+                  localMode === 'proxy'
+                    ? 'border-blue-500 bg-blue-500/10'
+                    : 'border-bambu-dark-tertiary hover:border-bambu-gray'
+                }`}
+              >
+                <div className="flex items-center gap-1.5 text-white font-medium">
+                  <ArrowRightLeft className="w-4 h-4" />
+                  {t('virtualPrinter.mode.proxy')}
+                </div>
+                <div className="text-xs text-bambu-gray">{t('virtualPrinter.mode.proxyDesc')}</div>
               </button>
             </div>
           </div>
@@ -313,11 +420,10 @@ export function VirtualPrinterSettings() {
               <AlertTriangle className="w-5 h-5 text-yellow-500 flex-shrink-0 mt-0.5" />
               <div className="text-sm">
                 <p className="text-white font-medium mb-2">
-                  Setup Required
+                  {t('virtualPrinter.setupRequired.title')}
                 </p>
                 <p className="text-bambu-gray mb-3">
-                  The virtual printer feature requires additional system configuration before it will work.
-                  This includes port forwarding, firewall rules, and platform-specific settings.
+                  {t('virtualPrinter.setupRequired.description')}
                 </p>
                 <a
                   href="https://wiki.bambuddy.cool/features/virtual-printer/"
@@ -326,7 +432,7 @@ export function VirtualPrinterSettings() {
                   className="inline-flex items-center gap-2 px-4 py-2 bg-yellow-500/20 border border-yellow-500/50 rounded-md text-yellow-400 hover:bg-yellow-500/30 transition-colors"
                 >
                   <ExternalLink className="w-4 h-4" />
-                  Read the setup guide before enabling
+                  {t('virtualPrinter.setupRequired.readGuide')}
                 </a>
               </div>
             </div>
@@ -340,16 +446,26 @@ export function VirtualPrinterSettings() {
               <Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
               <div className="text-sm text-bambu-gray">
                 <p className="mb-2">
-                  <strong className="text-white">How it works:</strong>
+                  <strong className="text-white">{localMode === 'proxy' ? t('virtualPrinter.howItWorks.titleProxy') : t('virtualPrinter.howItWorks.title')}:</strong>
                 </p>
-                <ol className="list-decimal list-inside space-y-1">
-                  <li>Complete the setup guide for your platform</li>
-                  <li>Enable the virtual printer and set an access code</li>
-                  <li>In Bambu Studio or OrcaSlicer, go to "Add Printer"</li>
-                  <li>The "Bambuddy" printer should appear in the discovery list</li>
-                  <li>Connect using the access code you set</li>
-                  <li>When you "print" to Bambuddy, the 3MF file is archived instead</li>
-                </ol>
+                {localMode === 'proxy' ? (
+                  <ol className="list-decimal list-inside space-y-1">
+                    <li>{t('virtualPrinter.howItWorks.proxyStep1')}</li>
+                    <li>{t('virtualPrinter.howItWorks.proxyStep2')}</li>
+                    <li>{t('virtualPrinter.howItWorks.proxyStep3')}</li>
+                    <li>{t('virtualPrinter.howItWorks.proxyStep4')}</li>
+                    <li>{t('virtualPrinter.howItWorks.proxyStep5')}</li>
+                  </ol>
+                ) : (
+                  <ol className="list-decimal list-inside space-y-1">
+                    <li>{t('virtualPrinter.howItWorks.step1')}</li>
+                    <li>{t('virtualPrinter.howItWorks.step2')}</li>
+                    <li>{t('virtualPrinter.howItWorks.step3')}</li>
+                    <li>{t('virtualPrinter.howItWorks.step4')}</li>
+                    <li>{t('virtualPrinter.howItWorks.step5')}</li>
+                    <li>{t('virtualPrinter.howItWorks.step6')}</li>
+                  </ol>
+                )}
               </div>
             </div>
           </CardContent>
@@ -359,31 +475,66 @@ export function VirtualPrinterSettings() {
         {status && isRunning && (
           <Card>
             <CardHeader>
-              <h3 className="text-md font-semibold text-white">Status Details</h3>
+              <h3 className="text-md font-semibold text-white">{t('virtualPrinter.status.title')}</h3>
             </CardHeader>
             <CardContent>
-              <div className="grid grid-cols-2 gap-4 text-sm">
-                <div>
-                  <div className="text-bambu-gray">Printer Name</div>
-                  <div className="text-white">{status.name}</div>
-                </div>
-                <div>
-                  <div className="text-bambu-gray">Model</div>
-                  <div className="text-white">{status.model_name || status.model}</div>
+              {status.mode === 'proxy' && status.proxy ? (
+                <div className="grid grid-cols-2 gap-4 text-sm">
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.targetPrinter')}</div>
+                    <div className="text-white">
+                      {printers?.find(p => p.id === localTargetPrinterId)?.name || status.proxy.target_host}
+                    </div>
+                    <div className="text-xs text-bambu-gray font-mono">{status.proxy.target_host}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.mode')}</div>
+                    <div className="text-white flex items-center gap-1.5">
+                      <ArrowRightLeft className="w-4 h-4" />
+                      {t('virtualPrinter.mode.proxy')}
+                    </div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.ftpPort')}</div>
+                    <div className="text-white font-mono">{status.proxy.ftp_port}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.mqttPort')}</div>
+                    <div className="text-white font-mono">{status.proxy.mqtt_port}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.ftpConnections')}</div>
+                    <div className="text-white">{status.proxy.ftp_connections}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.mqttConnections')}</div>
+                    <div className="text-white">{status.proxy.mqtt_connections}</div>
+                  </div>
                 </div>
-                <div>
-                  <div className="text-bambu-gray">Serial Number</div>
-                  <div className="text-white font-mono">{status.serial}</div>
-                </div>
-                <div>
-                  <div className="text-bambu-gray">Mode</div>
-                  <div className="text-white capitalize">{status.mode}</div>
-                </div>
-                <div>
-                  <div className="text-bambu-gray">Pending Files</div>
-                  <div className="text-white">{status.pending_files}</div>
+              ) : (
+                <div className="grid grid-cols-2 gap-4 text-sm">
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.printerName')}</div>
+                    <div className="text-white">{status.name}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.model')}</div>
+                    <div className="text-white">{status.model_name || status.model}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.serialNumber')}</div>
+                    <div className="text-white font-mono">{status.serial}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.mode')}</div>
+                    <div className="text-white capitalize">{status.mode}</div>
+                  </div>
+                  <div>
+                    <div className="text-bambu-gray">{t('virtualPrinter.status.pendingFiles')}</div>
+                    <div className="text-white">{status.pending_files}</div>
+                  </div>
                 </div>
-              </div>
+              )}
             </CardContent>
           </Card>
         )}

+ 20 - 2
frontend/src/contexts/ToastContext.tsx

@@ -1,4 +1,4 @@
-import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
+import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from 'react';
 import { CheckCircle, XCircle, AlertCircle, Info, X, Loader2 } from 'lucide-react';
 
 type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
@@ -44,15 +44,27 @@ const bgColors = {
 
 export function ToastProvider({ children }: { children: ReactNode }) {
   const [toasts, setToasts] = useState<Toast[]>([]);
+  const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
+
+  // Clean up all timeouts on unmount
+  useEffect(() => {
+    const timeouts = timeoutRefs.current;
+    return () => {
+      timeouts.forEach((timeout) => clearTimeout(timeout));
+      timeouts.clear();
+    };
+  }, []);
 
   const showToast = useCallback((message: string, type: ToastType = 'success') => {
     const id = Math.random().toString(36).substr(2, 9);
     setToasts((prev) => [...prev, { id, message, type }]);
 
     // Auto-dismiss after 3 seconds
-    setTimeout(() => {
+    const timeout = setTimeout(() => {
       setToasts((prev) => prev.filter((t) => t.id !== id));
+      timeoutRefs.current.delete(id);
     }, 3000);
+    timeoutRefs.current.set(id, timeout);
   }, []);
 
   const showPersistentToast = useCallback((id: string, message: string, type: ToastType = 'info') => {
@@ -67,6 +79,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
   }, []);
 
   const dismissToast = useCallback((id: string) => {
+    // Clear any pending auto-dismiss timeout
+    const timeout = timeoutRefs.current.get(id);
+    if (timeout) {
+      clearTimeout(timeout);
+      timeoutRefs.current.delete(id);
+    }
     setToasts((prev) => prev.filter((t) => t.id !== id));
   }, []);
 

+ 4 - 1
frontend/src/i18n/index.ts

@@ -5,10 +5,12 @@ import LanguageDetector from 'i18next-browser-languagedetector';
 // Import translations directly for bundling
 import en from './locales/en';
 import de from './locales/de';
+import ja from './locales/ja';
 
 const resources = {
   en: { translation: en },
   de: { translation: de },
+  ja: { translation: ja },
 };
 
 i18n
@@ -17,7 +19,7 @@ i18n
   .init({
     resources,
     fallbackLng: 'en',
-    supportedLngs: ['en', 'de'],
+    supportedLngs: ['en', 'de', 'ja'],
 
     detection: {
       // Order of detection methods
@@ -43,4 +45,5 @@ export default i18n;
 export const availableLanguages = [
   { code: 'en', name: 'English', nativeName: 'English' },
   { code: 'de', name: 'German', nativeName: 'Deutsch' },
+  { code: 'ja', name: 'Japanese', nativeName: '日本語' },
 ];

File diff suppressed because it is too large
+ 1018 - 20
frontend/src/i18n/locales/de.ts


File diff suppressed because it is too large
+ 1017 - 19
frontend/src/i18n/locales/en.ts


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

@@ -0,0 +1,3046 @@
+export default {
+  // Navigation
+  nav: {
+    printers: 'プリンター',
+    archives: 'アーカイブ',
+    queue: 'キュー',
+    stats: '統計',
+    profiles: 'プロファイル',
+    maintenance: 'メンテナンス',
+    projects: 'プロジェクト',
+    files: 'ファイル管理',
+    settings: '設定',
+    system: 'システム',
+    collapseSidebar: 'サイドバーを閉じる',
+    expandSidebar: 'サイドバーを開く',
+    update: 'アップデート',
+    updateAvailable: 'アップデートあり: v{{version}}',
+    viewOnGithub: 'GitHubで表示',
+    keyboardShortcuts: 'キーボードショートカット (?)',
+    switchToLight: 'ライトモードに切替',
+    switchToDark: 'ダークモードに切替',
+  },
+
+  // Common
+  common: {
+    save: '保存',
+    cancel: 'キャンセル',
+    delete: '削除',
+    edit: '編集',
+    add: '追加',
+    close: '閉じる',
+    confirm: '確認',
+    loading: '読み込み中...',
+    error: 'エラー',
+    success: '成功',
+    warning: '警告',
+    enabled: '有効',
+    disabled: '無効',
+    yes: 'はい',
+    no: 'いいえ',
+    on: 'オン',
+    off: 'オフ',
+    all: 'すべて',
+    none: 'なし',
+    search: '検索',
+    filter: 'フィルター',
+    sort: '並べ替え',
+    refresh: '更新',
+    download: 'ダウンロード',
+    upload: 'アップロード',
+    actions: '操作',
+    status: 'ステータス',
+    name: '名前',
+    description: '説明',
+    date: '日付',
+    time: '時間',
+    hours: '時間',
+    minutes: '分',
+    seconds: '秒',
+    noPrinters: 'プリンターが登録されていません',
+    noData: 'データがありません',
+    required: '必須',
+    optional: 'オプション',
+    unknown: '不明',
+    done: '完了',
+    saving: '保存中...',
+    saveChanges: '変更を保存',
+    today: '今日',
+    turnOn: 'オンにする',
+    turnOff: 'オフにする',
+    deletePhoto: '写真を削除',
+    deletePhotoConfirm: 'この写真を削除しますか?元に戻せません。',
+    create: '作成',
+    print: '印刷',
+    clear: 'クリア',
+    project: 'プロジェクト',
+    archive: 'アーカイブ',
+    unknownError: '不明なエラー',
+    show: '表示',
+    hide: '非表示',
+    back: '戻る',
+    export: 'エクスポート',
+    import: 'インポート',
+    retry: 'リトライ',
+    printer: 'プリンター',
+    model: 'モデル',
+    ok: 'OK',
+  },
+
+  // Printers page
+  printers: {
+    title: 'プリンター',
+    addPrinter: 'プリンターを追加',
+    editPrinter: 'プリンターを編集',
+    deletePrinter: 'プリンターを削除',
+    printerName: 'プリンター名',
+    serialNumber: 'シリアル番号',
+    ipAddress: 'IPアドレス',
+    accessCode: 'アクセスコード',
+    model: 'モデル',
+    nozzleCount: 'ノズル数',
+    autoArchive: '自動アーカイブ',
+    connected: '接続中',
+    offline: 'オフライン',
+    unknownModel: '不明なモデル',
+    statusLabel: 'ステータス',
+    status: {
+      printing: '印刷中',
+      paused: '一時停止',
+      finished: '完了',
+      failed: '失敗',
+      idle: '待機中',
+    },
+    lastPrint: '最後',
+    readyToPrint: '印刷可能',
+    estimatedCompletion: '完了予定時刻',
+    filaments: 'フィラメント',
+    controls: 'コントロール',
+    external: '外部',
+    externalSpool: '外部スプール',
+    files: 'ファイル',
+    browseFiles: 'プリンターのファイルを参照',
+    update: 'アップデート',
+    loading: 'プリンターを読み込み中...',
+    hideOffline: 'オフラインを非表示',
+    ungrouped: '未グループ',
+    hmsErrors: 'クリックしてHMSエラーを表示',
+    slotOptions: 'スロットオプション',
+    rereadRfid: 'RFIDを再読み取り',
+    clickToView: 'クリックして表示',
+    maintenanceUpToDate: 'すべてのメンテナンスが最新です',
+    maintenanceDue: '{{count}}件のメンテナンス期限',
+    maintenanceDueSoon: '{{count}}件のメンテナンスがまもなく期限',
+    printsInQueue: 'キューに{{count}}件の印刷',
+    firmwareUpdateAvailable: 'ファームウェアアップデートあり: {{current}} \u2192 {{latest}}',
+    statusSummary: {
+      printing: '印刷中',
+      idle: '待機中',
+      offline: 'オフライン',
+    },
+    sort: {
+      name: '名前',
+      status: 'ステータス',
+      model: 'モデル',
+      location: 'ロケーション',
+      ascending: '昇順で並べ替え',
+      descending: '降順で並べ替え',
+    },
+    cardSize: {
+      small: '小',
+      medium: '中',
+      large: '大',
+      extraLarge: '特大',
+      cards: 'カード',
+    },
+    temps: {
+      nozzle: 'ノズル',
+      bed: 'ベッド',
+      chamber: 'チャンバー',
+      left: '左',
+      right: '右',
+      activeNozzle: 'アクティブ: {{side}}ノズル',
+    },
+    fans: {
+      partCooling: 'パーツ冷却ファン',
+      auxiliary: '補助ファン',
+      chamber: 'チャンバーファン',
+    },
+    printControl: {
+      stop: '停止',
+      pause: '一時停止',
+      resume: '再開',
+      stopPrint: '印刷を停止',
+      pausePrint: '印刷を一時停止',
+      resumePrint: '印刷を再開',
+    },
+    power: {
+      on: 'オン',
+      off: 'オフ',
+      powerOn: '電源オン',
+      autoOff: '自動オフ',
+      autoOffDone: '自動オフ完了',
+      autoOffTooltip: '印刷後に自動電源オフ',
+      autoOffExecutedTooltip: '自動オフが実行されました - リセットするにはプリンターの電源を入れてください',
+      offlineWithPlugs: 'スマートプラグ付きオフラインプリンター',
+      noPlugs: 'スマートプラグ付きプリンターなし',
+    },
+    chamberLight: {
+      turnOn: 'チャンバーライトをオン',
+      turnOff: 'チャンバーライトをオフ',
+    },
+    camera: {
+      openOverlay: 'カメラオーバーレイを開く',
+      openWindow: 'カメラを新しいウィンドウで開く',
+    },
+    menu: {
+      reconnect: '再接続',
+      mqttDebug: 'MQTTデバッグ',
+    },
+    confirm: {
+      deleteTitle: 'プリンターを削除',
+      deleteMessage: '「{{name}}」を削除しますか?すべての接続設定が削除されます。',
+      deleteArchives: '印刷アーカイブを削除',
+      deleteArchivesYes: 'このプリンターのすべての印刷履歴が完全に削除されます。',
+      deleteArchivesNo: '印刷履歴は保持されますが、このプリンターとの関連付けは解除されます。',
+      powerOnTitle: 'プリンターの電源をオン',
+      powerOnMessage: '「{{name}}」の電源をオンにしますか?',
+      powerOn: '電源オン',
+      powerOffTitle: 'プリンターの電源をオフ',
+      powerOffMessage: '「{{name}}」の電源をオフにしますか?',
+      powerOffWarning: '警告: 「{{name}}」は現在印刷中です!電源をオフにしますか?印刷が中断され、プリンターが損傷する可能性があります。',
+      powerOff: '電源オフ',
+      stopTitle: '印刷を停止',
+      stopMessage: '「{{name}}」の現在の印刷を停止しますか?印刷ジョブがキャンセルされます。',
+      stopButton: '印刷を停止',
+      pauseTitle: '印刷を一時停止',
+      pauseMessage: '「{{name}}」の現在の印刷を一時停止しますか?',
+      pauseButton: '印刷を一時停止',
+      resumeTitle: '印刷を再開',
+      resumeMessage: '「{{name}}」の印刷を再開しますか?',
+      resumeButton: '印刷を再開',
+    },
+    skipObjects: {
+      title: 'オブジェクトスキップ',
+      onlyWhilePrinting: 'オブジェクトスキップ(印刷中のみ)',
+      requires2Objects: 'オブジェクトスキップ(2つ以上のオブジェクトが必要)',
+      noObjects: 'オブジェクトが見つかりません',
+      objectsLoadedOnStart: 'オブジェクトは印刷開始時に読み込まれます',
+      matchIds: 'プリンターのディスプレイとIDを照合してください',
+      printerScreenShows: 'プリンター画面にビルドプレート上のオブジェクトIDが表示されます',
+      skippedCount: '{{skipped}}/{{total}}スキップ済み',
+      waitForLayer: 'オブジェクトをスキップするにはレイヤー2以降をお待ちください(現在レイヤー{{layer}})',
+      activeCount: '{{count}}個アクティブ',
+      willBeSkipped: 'スキップされます',
+      waitForLayer2: 'レイヤー2以降をお待ちください',
+      skipThisObject: 'このオブジェクトをスキップ',
+      skip: 'スキップ',
+      skipped: 'スキップ済み',
+    },
+    form: {
+      name: '名前',
+      namePlaceholder: 'マイプリンター',
+      ipAddress: 'IPアドレス',
+      serialNumber: 'シリアル番号',
+      serialCannotChange: 'シリアル番号は変更できません',
+      accessCode: 'アクセスコード',
+      accessCodePlaceholder: 'プリンター設定から取得',
+      accessCodeKeepCurrent: '現在のコードを維持する場合は空のまま',
+      modelOptional: 'モデル(任意)',
+      model: 'モデル',
+      selectModel: 'モデルを選択...',
+      locationGroup: 'ロケーション / グループ',
+      locationPlaceholder: '例: 工房、オフィス、地下室',
+      locationHint: 'プリンターのグループ化とキュージョブのフィルタリングに使用',
+      autoArchive: '完了した印刷を自動アーカイブ',
+    },
+    discovery: {
+      subnetToScan: 'スキャンするサブネット',
+      dockerSubnetHint: 'Dockerが検出されました。CIDR表記でプリンターのサブネットを入力してください。docker-compose.ymlにnetwork_mode: hostが必要です。',
+      scanning: 'スキャン中...',
+      scanningProgress: 'スキャン中... {{scanned}}/{{total}}',
+      scanSubnet: 'サブネットをスキャンしてプリンターを検出',
+      discoverNetwork: 'ネットワーク上のプリンターを検出',
+      serialRequired: 'シリアル番号が必要です',
+      scanningSubnet: 'サブネットでBambuプリンターをスキャン中...',
+      scanningNetwork: 'ネットワークをスキャン中...',
+      noPrintersSubnet: '指定されたサブネットにプリンターが見つかりません。',
+      noPrintersNetwork: 'ネットワーク上にプリンターが見つかりません。',
+      allConfigured: '検出されたすべてのプリンターは既に設定済みです。',
+    },
+    firmware: {
+      title: 'ファームウェアアップデート',
+      current: '現在',
+      latest: '最新',
+      releaseNotes: 'リリースノート',
+      checkingPrerequisites: '前提条件を確認中...',
+      sdCardReady: 'SDカード準備完了。下をクリックしてファームウェアをアップロードしてください。',
+      uploadedToSd: 'ファームウェアをSDカードにアップロードしました!',
+      applyInstructions: 'プリンターでアップデートを適用するには:',
+      step1: 'プリンターのタッチスクリーンで設定に移動',
+      step2: 'ファームウェアに移動',
+      step3: 'SDカードからアップデートを選択',
+      step4: 'アップデートには10〜20分かかります',
+      starting: '開始中...',
+      uploadFirmware: 'ファームウェアをアップロード',
+      uploadSuccess: 'ファームウェアをアップロードしました!プリンター画面からアップデートを実行してください。',
+    },
+    empty: {
+      noPrinters: 'プリンターがまだ設定されていません',
+      addFirst: '最初のプリンターを追加',
+    },
+    toast: {
+      printStopped: '印刷を停止しました',
+      printPaused: '印刷を一時停止しました',
+      printResumed: '印刷を再開しました',
+      failedToStop: '印刷の停止に失敗しました',
+      failedToPause: '印刷の一時停止に失敗しました',
+      failedToResume: '印刷の再開に失敗しました',
+      chamberLightOn: 'チャンバーライトをオンにしました',
+      chamberLightOff: 'チャンバーライトをオフにしました',
+      failedChamberLight: 'チャンバーライトの制御に失敗しました',
+      objectsSkipped: 'オブジェクトをスキップしました',
+      failedToSkipObjects: 'オブジェクトのスキップに失敗しました',
+      rfidRereadInitiated: 'RFID再読み取りを開始しました',
+      failedRfidReread: 'RFID再読み取りに失敗しました',
+    },
+    progress: '{{percent}}% 完了',
+    timeRemaining: '残り {{time}}',
+    maintenanceOk: 'メンテナンス正常',
+    maintenanceWarning: '{{count}}件の警告',
+    maintenanceWarning_plural: '{{count}}件の警告',
+    colors: {
+      unknown: '不明',
+      black: '黒',
+      white: '白',
+      darkGray: 'ダークグレー',
+      lightGray: 'ライトグレー',
+      gray: 'グレー',
+      brown: '茶色',
+      red: '赤',
+      orange: 'オレンジ',
+      yellow: '黄色',
+      green: '緑',
+      cyan: 'シアン',
+      blue: '青',
+      purple: '紫',
+      pink: 'ピンク',
+    },
+    humidity: {
+      unknown: '不明',
+      good: '良好',
+      fair: '注意',
+      bad: '警告',
+    },
+    temperature: {
+      good: '良好',
+      fair: '注意',
+      bad: '高温',
+    },
+    wifi: {
+      excellent: '非常に良い',
+      good: '良い',
+      fair: '普通',
+      weak: '弱い',
+      veryWeak: '非常に弱い',
+    },
+    h2Series: 'H2シリーズ',
+    x1Series: 'X1シリーズ',
+    pSeries: 'Pシリーズ',
+    a1Series: 'A1シリーズ',
+    searchPresets: 'プリセットを検索...',
+    noCloudPresets: 'クラウドプリセットがありません。Bambu Cloudにログインして同期してください。',
+    noMatchingPresets: '一致するプリセットが見つかりません。',
+    colorNamePlaceholder: '色の名前またはHex値(例:brown、FF8800)',
+    clearCustomColor: 'カスタムカラーをクリア',
+    copySpoolUuid: 'スプールUUIDをコピー',
+    configureSlot: 'フィラメントプロファイルとK値でスロットを設定',
+  },
+
+  // Archives page
+  archives: {
+    title: '印刷アーカイブ',
+    searchPlaceholder: 'アーカイブを検索...',
+    filterByPrinter: 'プリンターで絞り込み',
+    filterByStatus: 'ステータスで絞り込み',
+    sortBy: '並べ替え',
+    sortNewest: '新しい順',
+    sortOldest: '古い順',
+    sortName: '名前順',
+    sortDuration: '時間順',
+    noArchives: 'アーカイブが見つかりません',
+    printTime: '印刷時間',
+    filamentUsed: 'フィラメント使用量',
+    cost: 'コスト',
+    reprint: '再印刷',
+    preview: 'プレビュー',
+    deleteArchive: 'アーカイブを削除',
+    deleteConfirm: 'このアーカイブを削除しますか?',
+    favorite: 'お気に入り',
+    unfavorite: 'お気に入りから削除',
+    viewDetails: '詳細を表示',
+    status: {
+      completed: '完了',
+      failed: '失敗',
+      stopped: '中止',
+    },
+    // Collections
+    collections: {
+      all: 'すべてのアーカイブ',
+      recent: '過去24時間',
+      'this-week': '今週',
+      'this-month': '今月',
+      favorites: 'お気に入り',
+      failed: '失敗した印刷',
+      duplicates: '重複',
+    },
+    // Bulk operations
+    bulkDeleted: '{{count}}件のアーカイブを削除しました',
+    bulkDeleteFailed: 'アーカイブの削除に失敗しました',
+    bulkDeleteTitle: 'アーカイブを削除',
+    bulkDeleteConfirm: '{{count}}件のアーカイブを削除しますか?この操作は元に戻せません。',
+    deleteCount: '{{count}}件を削除',
+    // Drag & drop
+    only3mfSupported: '.3mfファイルのみ対応しています',
+    dropFilesHere: '.3mfファイルをここにドロップ',
+    releaseToUpload: 'ドロップしてアップロード',
+    // Selection toolbar
+    selectedCount: '{{count}}件選択中',
+    selectAll: 'すべて選択',
+    tags: 'タグ',
+    project: 'プロジェクト',
+    toggledFavorites: '{{count}}件のアーカイブのお気に入りを切替えました',
+    favoriteUpdateFailed: 'お気に入りの更新に失敗しました',
+    favoriteBulk: 'お気に入り',
+    // Page header
+    showingCount: '{{total}}件中{{shown}}件を表示',
+    export: 'エクスポート',
+    exportDownloaded: 'エクスポートをダウンロードしました',
+    exportFailed: 'エクスポートに失敗しました',
+    exportCsv: 'CSVでエクスポート',
+    exportExcel: 'Excelでエクスポート',
+    compare: '比較 ({{count}})',
+    select: '選択',
+    upload3mf: '3MFアップロード',
+    // Filters
+    allPrinters: 'すべてのプリンター',
+    allMaterials: 'すべての素材',
+    allFiles: 'すべてのファイル',
+    slicedGcode: 'スライス済み (GCODE)',
+    sourceFileOnly: 'ソースのみ',
+    showAll: 'すべて表示',
+    showFavoritesOnly: 'お気に入りのみ表示',
+    favorites: 'お気に入り',
+    showFailedPrints: '失敗した印刷を表示',
+    hideFailedPrints: '失敗した印刷を非表示',
+    hideFailed: '失敗を非表示',
+    allTags: 'すべてのタグ',
+    enterNewTag: '新しいタグを入力...',
+    sortNewestFirst: '新しい順',
+    sortOldestFirst: '古い順',
+    sortNameAZ: '名前 A-Z',
+    sortNameZA: '名前 Z-A',
+    sortLargestFirst: '大きい順',
+    sortSmallestFirst: '小さい順',
+    gridView: 'グリッド表示',
+    listView: 'リスト表示',
+    calendarView: 'カレンダー表示',
+    reset: 'リセット',
+    colors: 'カラー:',
+    matchAnyColor: 'いずれかの選択色に一致',
+    matchAllColors: 'すべての選択色に一致',
+    clear: 'クリア',
+    // Content
+    loading: 'アーカイブを読み込み中...',
+    noMatchingArchives: '検索に一致するアーカイブはありません',
+    noArchivesYet: 'アーカイブはまだありません',
+    autoCreated: '印刷完了時にアーカイブは自動的に作成されます',
+    unknownPrinter: '不明',
+    noPrinter: 'プリンターなし',
+    // ツールチップ
+    rightClickOptions: '右クリックでオプション表示',
+    printedBefore: 'このモデルは以前印刷されたことがあります',
+    openSource3mf: 'ソース3MFをBambu Studioで開く(右クリックでオプション表示)',
+    viewPhotos: '{{count}}枚の写真を表示',
+    openFolder: 'フォルダーを開く: {{name}}',
+    projectName: 'プロジェクト: {{name}}',
+    makerWorldDesigner: 'MakerWorld: {{designer}}',
+    notFromMakerWorld: 'MakerWorldではありません',
+    hasTimelapse: 'タイムラプスあり',
+    moreOptions: 'その他のオプション',
+    downloadF3d: 'Fusion 360デザインファイルをダウンロード',
+    // List view headers
+    listName: '名前',
+    listPrinter: 'プリンター',
+    listDate: '日付',
+    listSize: 'サイズ',
+    listActions: 'アクション',
+  },
+
+  // Queue page
+  queue: {
+    title: '印刷キュー',
+    description: '印刷ジョブのスケジュールと管理',
+    addToQueue: 'キューに追加',
+    clearQueue: 'キューをクリア',
+    clearHistory: '履歴をクリア',
+    emptyQueue: 'キューは空です',
+    position: '順番',
+    scheduledTime: '予定時刻',
+    moveUp: '上に移動',
+    moveDown: '下に移動',
+    remove: '削除',
+    requeue: '再キュー',
+    startNow: '今すぐ開始',
+    startPrint: '印刷を開始',
+    stopPrint: '印刷を停止',
+    unassigned: '未割当',
+    printerNumber: 'プリンター #{{id}}',
+    staged: 'ステージ済み',
+    requiresPreviousSuccess: '前の印刷の成功が必要',
+    autoPowerOff: '自動電源オフ',
+    printingInProgress: '印刷中...',
+    currentlyPrinting: '印刷中',
+    viewArchive: 'アーカイブを表示',
+    viewInFileManager: 'ファイルマネージャーで表示',
+    itemCount: '{{count}}件',
+    dragToReorder: 'ドラッグして並べ替え(ASAPのみ)',
+    overdue: '期限超過',
+    dragReorderTooltip: '順番はASAPアイテムのみに影響します。スケジュール済みアイテムは設定された時間に実行されます。',
+    anyModel: '任意の{{model}}{{location}}{{filaments}}',
+    addedBy: '{{username}}が追加',
+    bulkEdit: {
+      title: '{{count}}件のアイテムを編集',
+      description: '変更した設定のみが選択されたアイテムに適用されます。',
+      printer: 'プリンター',
+      noChange: '— 変更なし —',
+      queueOptions: 'キューオプション',
+      stagedManualStart: 'ステージ済み(手動開始)',
+      autoPowerOff: '印刷後に自動電源オフ',
+      requirePreviousSuccess: '前の印刷の成功が必要',
+      printOptions: '印刷オプション',
+      bedLevelling: 'ベッドレベリング',
+      flowCalibration: 'フローキャリブレーション',
+      vibrationCalibration: '振動キャリブレーション',
+      firstLayerInspection: '第一層検査',
+      timelapse: 'タイムラプス',
+      useAms: 'AMS使用',
+      on: 'オン',
+      off: 'オフ',
+      saving: '保存中...',
+      applyChanges: '変更を適用',
+    },
+    bulkActions: {
+      selectAll: 'すべて選択',
+      deselectAll: 'すべて選択解除',
+      selected: '{{count}}件選択中',
+      editSelected: '選択を編集',
+      cancelSelected: '選択をキャンセル',
+    },
+    permissions: {
+      noStopPermission: '印刷を停止する権限がありません',
+      noStartPermission: '印刷を開始する権限がありません',
+      noEditPermission: 'キューアイテムを編集する権限がありません',
+      noCancelPermission: 'キューアイテムをキャンセルする権限がありません',
+      noRequeuePermission: 'アイテムを再キューする権限がありません',
+      noRemovePermission: 'キューアイテムを削除する権限がありません',
+      noClearHistoryPermission: '履歴をクリアする権限がありません',
+    },
+    status: {
+      pending: '待機中',
+      printing: '印刷中',
+      completed: '完了',
+      failed: '失敗',
+      skipped: 'スキップ',
+      cancelled: 'キャンセル済み',
+      waiting: '待機中',
+    },
+    summary: {
+      printing: '印刷中',
+      queued: 'キュー中',
+      totalQueueTime: '合計キュー時間',
+      history: '履歴',
+    },
+    filters: {
+      allPrinters: 'すべてのプリンター',
+      allStatus: 'すべてのステータス',
+    },
+    sort: {
+      byPosition: '順番で並べ替え',
+      byName: '名前で並べ替え',
+      byPrinter: 'プリンターで並べ替え',
+      bySchedule: 'スケジュールで並べ替え',
+      byDate: '日付で並べ替え',
+      ascending: '昇順',
+      descending: '降順',
+      ascendingOldest: '昇順(古い順)',
+      descendingNewest: '降順(新しい順)',
+    },
+    empty: {
+      title: 'スケジュールされた印刷はありません',
+      description: 'アーカイブページのコンテキストメニューから「スケジュール」オプションを使用するか、ファイルをドラッグ&ドロップして始めましょう。',
+    },
+    confirm: {
+      cancelTitle: 'スケジュール済み印刷をキャンセル',
+      cancelMessage: '「{{name}}」をキャンセルしますか?',
+      cancelButton: '印刷をキャンセル',
+      stopTitle: '印刷を停止',
+      stopMessage: '現在の印刷「{{name}}」を停止しますか?プリンター上の印刷ジョブがキャンセルされます。',
+      stopButton: '印刷を停止',
+      removeTitle: '履歴から削除',
+      removeMessage: '「{{name}}」をキュー履歴から削除しますか?',
+      clearHistoryMessage: '{{count}}件の履歴をすべて削除しますか?',
+      thisPrint: 'この印刷',
+      thisItem: 'このアイテム',
+    },
+    toast: {
+      itemCancelled: 'キューアイテムをキャンセルしました',
+      itemRemoved: 'キューアイテムを削除しました',
+      printStopped: '印刷を停止しました',
+      printReleased: '印刷をキューに戻しました',
+      clearedHistory: '{{count}}件の履歴をクリアしました',
+      failedToCancel: 'アイテムのキャンセルに失敗しました',
+      failedToRemove: 'アイテムの削除に失敗しました',
+      failedToStop: '印刷の停止に失敗しました',
+      failedToStart: '印刷の開始に失敗しました',
+      failedToReorder: 'キューの並べ替えに失敗しました',
+      failedToClear: '履歴のクリアに失敗しました',
+      failedToUpdateItems: 'アイテムの更新に失敗しました',
+      cancelledItems: '{{count}}件のアイテムをキャンセルしました',
+      failedToCancelItems: 'アイテムの一括キャンセルに失敗しました',
+    },
+  },
+
+  // Statistics page
+  stats: {
+    title: '統計',
+    overview: '概要',
+    totalPrints: '総印刷数',
+    successRate: '成功率',
+    totalPrintTime: '総印刷時間',
+    totalFilament: '総フィラメント使用量',
+    totalCost: '総コスト',
+    averagePrintTime: '平均印刷時間',
+    printsPerDay: '1日あたりの印刷数',
+    byPrinter: 'プリンター別',
+    byMaterial: '素材別',
+    byMonth: '月別',
+    last7Days: '過去7日間',
+    last30Days: '過去30日間',
+    last90Days: '過去90日間',
+    allTime: '全期間',
+    // ダッシュボード
+    dashboard: '統計ダッシュボード',
+    dashboardDescription: '3Dプリントアクティビティの概要',
+    loadingStatistics: '統計を読み込み中...',
+    // クイック統計
+    quickStats: 'クイック統計',
+    printTime: '印刷時間',
+    filamentUsed: 'フィラメント使用量',
+    filamentCost: 'フィラメントコスト',
+    energyUsed: 'エネルギー使用量',
+    energyCost: 'エネルギーコスト',
+    // 成功率
+    successful: '成功',
+    failed: '失敗',
+    printsByPrinter: 'プリンター別印刷数',
+    printerFallback: 'プリンター #{{id}}',
+    // 時間精度
+    timeAccuracy: '時間精度',
+    noTimeAccuracyData: '時間精度データがありません',
+    perfectEstimate: '100% = 完全な推定',
+    // フィラメントタイプ
+    filamentTypes: 'フィラメントタイプ',
+    noFilamentData: 'フィラメントデータがありません',
+    printsCount: '{{count}}回印刷',
+    moreTypes: '+{{count}}種類',
+    // プリンター別印刷数
+    noPrinterData: 'プリンターデータがありません',
+    // フィラメントトレンド
+    filamentUsageTrends: 'フィラメント使用トレンド',
+    noPrintData: '印刷データがありません',
+    // 印刷アクティビティ
+    printActivity: '印刷アクティビティ',
+    // 失敗分析
+    failureAnalysis30Days: '失敗分析(30日間)',
+    noPrintDataLast30Days: '過去30日間の印刷データがありません',
+    printsFailed: '{{total}}回中{{failed}}回失敗',
+    lastWeek: '先週',
+    failureReasons: '失敗理由',
+    topFailureReasons: '主な失敗理由',
+    unknown: '不明',
+    moreReasons: '+{{count}}件の理由',
+    // レイアウトとエクスポート
+    hiddenCount: '{{count}}件非表示',
+    layoutReset: 'レイアウトをリセットしました',
+    resetLayout: 'レイアウトをリセット',
+    exportStats: '統計をエクスポート',
+    exportAsCsv: 'CSVでエクスポート',
+    exportAsExcel: 'Excelでエクスポート',
+    exportDownloaded: 'エクスポートをダウンロードしました',
+    exportFailed: 'エクスポートに失敗しました',
+    filament: 'フィラメント',
+    cost: 'コスト',
+    prints: 'プリント',
+    filamentGrams: 'フィラメント (g)',
+    // 追加の統計
+    weekOf: '週:',
+    periodFilament: '期間フィラメント',
+    periodCost: '期間コスト',
+    avgPerPrint: 'プリントあたりの平均',
+    total: '合計',
+    avg: '平均',
+    usageOverTime: '使用量の推移',
+    noDataForTimeRange: '選択した期間のデータがありません',
+    byFilamentType: 'フィラメントタイプ別',
+    usage: '使用量',
+    monthlyComparison: '月間比較',
+    noPermissionRecalculate: 'コストを再計算する権限がありません',
+    noPermissionResetLayout: 'レイアウトをリセットする権限がありません',
+    recalculateCosts: 'コストを再計算',
+    recalculateFailed: 'コストの再計算に失敗しました',
+    recalculateTooltip: '現在のフィラメント価格に基づいてすべてのプリントコストを再計算します',
+  },
+
+  // Profiles page
+  profiles: {
+    title: 'フィラメントプロファイル',
+    addProfile: 'プロファイルを追加',
+    editProfile: 'プロファイルを編集',
+    deleteProfile: 'プロファイルを削除',
+    material: '素材',
+    brand: 'ブランド',
+    color: '色',
+    diameter: '直径',
+    density: '密度',
+    costPerKg: '1kgあたりの価格',
+    spoolWeight: 'スプール重量',
+    noProfiles: 'プロファイルが登録されていません',
+    deleteConfirm: 'このプロファイルを削除しますか?',
+    templateName: 'テンプレート名',
+    descriptionOptional: '説明(任意)',
+    searchFields: 'フィールドを検索...',
+    presetNamePlaceholder: 'マイカスタムプリセット',
+    searchPresets: 'プリセットを検索...',
+    // Cloud login
+    connectToCloud: 'Bambu Cloudに接続',
+    syncDescription: 'デバイス間でスライサープリセットを同期',
+    email: 'メールアドレス',
+    password: 'パスワード',
+    region: 'リージョン',
+    regionGlobal: 'グローバル',
+    regionChina: '中国',
+    verificationCode: '認証コード',
+    totpCode: '認証アプリコード',
+    checkEmail: 'メール ({{email}}) に届いた6桁のコードを入力してください',
+    enterTotpHint: '認証アプリの6桁のコードを入力してください',
+    accessToken: 'アクセストークン',
+    accessTokenHint: 'Bambu Labのアクセストークンを貼り付け(Bambu Studioから取得)',
+    login: 'ログイン',
+    verify: '認証',
+    setToken: 'トークン設定',
+    useTokenInstead: 'アクセストークンを使用',
+    loginWithEmail: 'メールでログイン',
+    loggedIn: 'ログインしました',
+    verificationSent: '認証コードをメールに送信しました',
+    enterTotp: '認証アプリのコードを入力してください',
+    tokenSet: 'トークンを設定しました',
+    loggedOut: 'ログアウトしました',
+    logout: 'ログアウト',
+    connectedAs: '接続中:',
+    // Page
+    pageTitle: 'プロファイル',
+    pageDescription: 'スライサープリセットと圧力キャリブレーションの管理',
+    cloudProfiles: 'クラウドプロファイル',
+    kProfiles: 'Kプロファイル',
+    scrollToTop: 'トップに戻る',
+    // Time
+    justNow: 'たった今',
+    minutesAgo: '{{count}}分前',
+    hoursAgo: '{{count}}時間前',
+    daysAgo: '{{count}}日前',
+    lastSynced: '最終同期:',
+    // Presets
+    duplicate: '複製',
+    myPresetEditable: 'マイプリセット(編集可能)',
+    editable: '編集可能',
+    typePreset: '{{type}}プリセット',
+    presetDeleted: 'プリセットを削除しました',
+    presetCreated: 'プリセットを作成しました',
+    presetUpdated: 'プリセットを更新しました',
+    presetExported: 'プリセットをエクスポートしました',
+    presetName: 'プリセット名',
+    failedToLoadDetails: 'プリセットの詳細を読み込めませんでした',
+    failedToLoadProfiles: 'プロファイルの読み込みに失敗しました',
+    deletePresetConfirm: 'このプリセットを削除しますか?',
+    deletePresetWarning: '"{{name}}"をBambu Cloudから完全に削除します。この操作は元に戻せません。',
+    noPresetsFound: 'プリセットが見つかりません',
+    showingPresets: '{{total}}件中{{count}}件を表示',
+    myPresetLegend: '= マイプリセット(編集可能)',
+    // Filters
+    typeLabel: 'タイプ',
+    typeFilter: 'タイプ:',
+    owner: '所有者',
+    myPresets: 'マイプリセット',
+    builtIn: 'ビルトイン',
+    nozzle: 'ノズル',
+    filament: 'フィラメント',
+    process: 'プロセス',
+    printer: 'プリンター',
+    layer: 'レイヤー',
+    clearFilters: 'フィルターをクリア',
+    noFilamentPresets: 'フィラメントプリセットなし',
+    noProcessPresets: 'プロセスプリセットなし',
+    noPrinterPresets: 'プリンタープリセットなし',
+    // Compare
+    compare: '比較',
+    compareMode: '比較モード',
+    comparePresets: 'プリセットを比較',
+    compareNow: '比較を実行',
+    compareWithBase: 'ベースプリセットと比較',
+    selectAnotherPreset: '同じタイプ({{type}})の別のプリセットを選択',
+    clickTwoPresets: '同じタイプのプリセットを2つクリックして比較',
+    selectFirst: '1. 最初を選択',
+    selectSecond: '2. 2番目を選択',
+    left: '左:',
+    right: '右:',
+    addedCount: '{{count}}件追加',
+    removedCount: '{{count}}件削除',
+    changedCount: '{{count}}件変更',
+    sameCount: '{{count}}件同じ',
+    changes: '変更点',
+    noDifferences: '差分はありません',
+    noFieldsMatch: '検索に一致するフィールドがありません',
+    field: 'フィールド',
+    // Templates
+    quickTemplates: 'クイックテンプレート',
+    templates: 'テンプレート',
+    deleteTemplate: 'テンプレートを削除',
+    actionCannotBeUndone: 'この操作は元に戻せません',
+    confirmDeleteTemplate: '"{{name}}"を削除してもよろしいですか?',
+    templateDeleted: 'テンプレートを削除しました',
+    templateUpdated: 'テンプレートを更新しました',
+    templateApplied: 'テンプレートを適用しました',
+    templateAppliedName: 'テンプレート適用: {{name}}',
+    templateSaved: 'テンプレートを保存しました',
+    customTemplate: 'カスタムテンプレート',
+    noTemplatesYet: 'テンプレートはまだありません',
+    createTemplatesHint: 'プリセットエディターからテンプレートを作成',
+    noTemplatesSelected: 'テンプレートが選択されていません。テンプレートボタンから有効にしてください。',
+    manageTemplatesHint: 'メインページのテンプレートボタンからテンプレートを管理',
+    settingsJson: '設定 (JSON)',
+    fieldsCount: '{{count}}フィールド',
+    shownInModals: 'モーダルに表示',
+    hiddenInModals: 'モーダルで非表示',
+    apply: '適用',
+    saveAsTemplate: 'テンプレートとして保存',
+    // Create/Edit preset
+    editPreset: 'プリセットを編集',
+    duplicatePreset: 'プリセットを複製',
+    createNewPreset: '新しいプリセットを作成',
+    newPreset: '新規プリセット',
+    customizeSettings: '新しいプリセットの設定をカスタマイズ',
+    dropJsonToImport: 'JSONファイルをドロップしてインポート',
+    basePreset: 'ベースプリセット',
+    selectBasePreset: 'ベースプリセットを選択...',
+    inheritsFrom: '継承元:',
+    commonTab: '共通',
+    allFieldsTab: '全フィールド',
+    jsonTab: 'JSON',
+    exportToJsonFile: '設定をJSONファイルにエクスポート',
+    importFromJsonFile: 'JSONファイルから設定をインポート',
+    commonSettings: '共通設定',
+    currentOverrides: '現在のオーバーライド',
+    availableFields: '利用可能なフィールド',
+    noMatchingFields: '一致するフィールドがありません',
+    allFieldsAdded: 'すべてのフィールドが追加済みです',
+    addCustomField: 'カスタムフィールドを追加',
+    yourOverrides: 'オーバーライド一覧',
+    noOverridesYet: 'オーバーライドはまだありません',
+    clickFieldsToAdd: '左のフィールドをクリックして追加',
+    dragDropTip: 'ヒント: このモーダルに.jsonファイルをドラッグ&ドロップして設定をインポート',
+    fieldAdded: 'フィールド "{{key}}" を追加しました',
+    noOverridesToSave: '保存するオーバーライドがありません',
+    fileImported: 'ファイルをインポートしました',
+    fileImportedSuccess: 'ファイルを正常にインポートしました',
+    invalidJson: '無効なJSON',
+    // 権限
+    noPermissionCreate: 'プロファイルを作成する権限がありません',
+    noPermissionDelete: 'プロファイルを削除する権限がありません',
+    noPermissionDuplicate: 'プロファイルを複製する権限がありません',
+    noPermissionEdit: 'プロファイルを編集する権限がありません',
+    noPermissionLogout: 'ログアウトする権限がありません',
+    noPermissionManageTemplates: 'テンプレートを管理する権限がありません',
+    noPermissionRefresh: 'プロファイルを更新する権限がありません',
+  },
+
+  // Maintenance page
+  maintenance: {
+    title: 'メンテナンス',
+    overview: '概要',
+    allOk: 'すべてのメンテナンスは最新です',
+    dueCount: '{{count}}件の期限到来',
+    dueCount_plural: '{{count}}件の期限到来',
+    warningCount: '{{count}}件の警告',
+    warningCount_plural: '{{count}}件の警告',
+    totalPrintTime: '総印刷時間',
+    nextMaintenance: '次回メンテナンス',
+    nothingDue: '予定なし',
+    tasks: 'タスク',
+    lastPerformed: '前回実施日',
+    interval: '間隔',
+    hoursRemaining: '残り{{hours}}時間',
+    hoursOverdue: '{{hours}}時間超過',
+    markDone: '完了にする',
+    performMaintenance: 'メンテナンスを実施',
+    history: '履歴',
+    noHistory: 'メンテナンス履歴がありません',
+    editPrintHours: '印刷時間を編集',
+    currentHours: '現在の時間',
+    today: '今日',
+    overdue: '期限超過',
+    dueSoon: 'まもなく期限',
+    collapse: '折りたたむ',
+    expand: '展開',
+    left: '残り',
+    timeBasedInterval: '時間ベースのインターバル',
+    viewDocumentation: 'ドキュメントを表示',
+    printersAssigned: '{{count}}台のプリンターが割り当て済み - クリックして管理',
+    deleteTypeConfirm: '「{{name}}」を削除しますか?',
+    removeFromPrinter: 'このプリンターから削除',
+    addLabel: '追加:',
+    maintenanceTypes: 'メンテナンスタイプ',
+    systemTypesDescription: 'システムタイプとカスタムメンテナンスタスク',
+    addCustomType: 'カスタムタイプを追加',
+    intervalType: 'インターバルタイプ',
+    printHours: '印刷時間',
+    calendarDays: 'カレンダー日数',
+    intervalWithUnit: 'インターバル ({{unit}})',
+    days: '日',
+    icon: 'アイコン',
+    documentationLink: 'ドキュメントリンク(任意)',
+    docLinkPlaceholder: 'ドキュメントリンク(任意)',
+    namePlaceholder: '例:HEPAフィルター交換',
+    assignToPrinters: 'プリンターに割り当て',
+    selectAtLeastOnePrinter: 'プリンターを1台以上選択してください',
+    addType: 'タイプを追加',
+    custom: 'カスタム',
+    assignedToPrinters: '割り当て済みプリンター:',
+    noPrintersAssigned: 'プリンター未割り当て',
+    intervalOverrides: 'インターバルのオーバーライド',
+    customizeIntervals: 'プリンターごとにインターバルをカスタマイズ',
+    addPrintersForMaintenance: 'メンテナンスインターバルを設定するにはプリンターを追加してください',
+    // ページレベルの文字列
+    reset: 'リセット',
+    countOverdue: '{{count}}件が期限超過',
+    countDueSoon: '{{count}}件がまもなく期限',
+    allGood: '問題なし',
+    nHours: '{{count}}時間',
+    tasksOverdue: '{{count}}件のタスクが期限超過',
+    configureDescription: 'メンテナンスタイプとインターバルを設定',
+    addPrintersToTrack: 'メンテナンスを追跡するにはプリンターを追加してください',
+    markedComplete: 'メンテナンスを完了としてマーク',
+    typeUpdated: 'メンテナンスタイプを更新しました',
+    typeDeleted: 'メンテナンスタイプを削除しました',
+    typeAdded: 'メンテナンスタイプを追加しました',
+    hoursUpdated: '印刷時間を更新しました',
+    printerAssigned: 'プリンターを割り当てました',
+    printerRemoved: 'プリンターを削除しました',
+    wikiUrlPlaceholder: 'https://wiki.bambulab.com/...',
+    // 期間フォーマット
+    duration: {
+      oneDay: '1日',
+      days: '{{count}}日',
+      weeks: '{{count}}週間',
+      months: '{{count}}ヶ月',
+      oneWeek: '1週間',
+      nWeeks: '{{count}}週間',
+      oneMonth: '1ヶ月',
+      nMonths: '{{count}}ヶ月',
+      oneYear: '1年',
+    },
+    // 権限
+    noPermissionCreate: 'メンテナンス記録を作成する権限がありません',
+    noPermissionUpdate: 'メンテナンス記録を更新する権限がありません',
+    noPermissionPerform: 'メンテナンスを実行する権限がありません',
+    noPermissionRemove: 'メンテナンス記録を削除する権限がありません',
+    noPermissionAssign: 'メンテナンスを割り当てる権限がありません',
+    noPermissionEditTypes: 'メンテナンスタイプを編集する権限がありません',
+    noPermissionDeleteTypes: 'メンテナンスタイプを削除する権限がありません',
+    noPermissionEditIntervals: 'メンテナンス間隔を編集する権限がありません',
+    noPermissionEditHours: 'メンテナンス時間を編集する権限がありません',
+    types: {
+      lubricateRails: 'リニアレールの潤滑',
+      cleanNozzle: 'ノズル/ホットエンドの清掃',
+      checkBelts: 'ベルト張力の確認',
+      cleanBuildPlate: 'ビルドプレートの清掃',
+      checkExtruder: 'エクストルーダーギアの確認',
+      checkCooling: '冷却ファンの確認',
+      generalInspection: '総合点検',
+      cleanCarbonRods: 'カーボンロッドの清掃',
+      checkPtfeTube: 'PTFEチューブの確認',
+      replaceHepaFilter: 'HEPAフィルター交換',
+      replaceCarbonFilter: 'カーボンフィルター交換',
+      lubricateLeftNozzleRail: '左ノズルレールの潤滑',
+    },
+  },
+
+  // Settings page
+  settings: {
+    title: '設定',
+    general: '一般',
+    appearance: '外観',
+    notifications: '通知',
+    smartPlugs: 'スマートプラグ',
+    tabSpoolman: 'Spoolman',
+    updates: 'アップデート',
+    language: '言語',
+    languageDescription: '表示言語を選択してください',
+    theme: 'テーマ',
+    themeLight: 'ライト',
+    themeDark: 'ダーク',
+    themeSystem: 'システム設定に従う',
+    defaultView: 'デフォルト画面',
+    defaultViewDescription: 'アプリ起動時に表示するページ',
+    checkForUpdates: 'アップデートを確認',
+    autoUpdate: '自動アップデート',
+    currentVersion: '現在のバージョン',
+    latestVersion: '最新バージョン',
+    upToDate: '最新です',
+    updateAvailable: 'アップデートあり',
+    telemetry: '匿名テレメトリ',
+    telemetryDescription: '匿名の使用状況データを送信してBamBuddyの改善に協力する',
+    telemetryLearnMore: '詳しく見る',
+    telemetryInfoTitle: 'どのようなデータが収集されますか?',
+    telemetryInfoIntro: 'BamBuddyはアプリの利用者数、使用バージョン、プリンターモデルを把握するために最小限の匿名データを収集します。これはバグ修正や新機能の優先順位付けに役立ちます。',
+    telemetryInfoCollected: '収集するデータ:',
+    telemetryInfoItem1: 'ランダムなインストールID(個人やハードウェアとは紐付きません)',
+    telemetryInfoItem2: '使用中のアプリバージョン',
+    telemetryInfoItem3: 'プリンターのモデル名(例: X1C, P1S)- 名前やシリアル番号は含みません',
+    telemetryInfoItem4: 'タイムスタンプ(日次/週次のアクティブユーザー数の計測用)',
+    telemetryInfoNotCollected: '収集しないデータ:',
+    telemetryInfoNotItem1: 'IPアドレスはハッシュ化され、復元できません',
+    telemetryInfoNotItem2: 'プリンター名、シリアル番号、アクセスコード',
+    telemetryInfoNotItem3: '印刷履歴、ファイル名、その他の個人的なコンテンツ',
+    telemetryInfoNotItem4: '個人を特定できる情報',
+    telemetryInfoFooter: 'テレメトリはいつでも無効にできます。インストールIDはランダムに生成され、個人を追跡することはできません。',
+    // Notifications
+    notificationLanguage: '通知の言語',
+    notificationLanguageDescription: 'プッシュ通知の言語',
+    notificationProviders: '通知プロバイダー',
+    addProvider: 'プロバイダーを追加',
+    editProvider: 'プロバイダーを編集',
+    providerType: 'プロバイダーの種類',
+    testNotification: 'テスト通知',
+    testSuccess: 'テスト通知を送信しました',
+    testFailed: 'テスト通知の送信に失敗しました',
+    quietHours: 'おやすみ時間',
+    quietHoursDescription: 'この時間帯は通知を送信しません',
+    quietHoursStart: '開始',
+    quietHoursEnd: '終了',
+    events: {
+      title: '通知イベント',
+      printStart: '印刷開始',
+      printComplete: '印刷完了',
+      printFailed: '印刷失敗',
+      printStopped: '印刷中止',
+      printProgress: '進捗マイルストーン',
+      printProgressDescription: '25%, 50%, 75%で通知',
+      printerOffline: 'プリンターオフライン',
+      printerError: 'プリンターエラー',
+      filamentLow: 'フィラメント残量低下',
+      maintenanceDue: 'メンテナンス期限',
+      maintenanceDueDescription: 'メンテナンスが必要なときに通知',
+    },
+    // Smart Plugs
+    smartPlug: {
+      title: 'スマートプラグ',
+      add: 'スマートプラグを追加',
+      edit: 'スマートプラグを編集',
+      name: '名前',
+      ipAddress: 'IPアドレス',
+      linkedPrinter: '連携プリンター',
+      autoOn: '自動電源オン',
+      autoOnDescription: '印刷開始時に電源を入れる',
+      autoOff: '自動電源オフ',
+      autoOffDescription: '印刷完了後に電源を切る',
+      offDelay: 'オフ遅延',
+      offDelayMinutes: '印刷後の待機時間(分)',
+      offDelayTemp: 'ノズル温度が下回ったとき',
+      currentState: '現在の状態',
+      turnOn: '電源オン',
+      turnOff: '電源オフ',
+    },
+    // Spoolman
+    spoolman: {
+      title: 'Spoolman連携',
+      description: 'Spoolmanに接続してフィラメント在庫を管理します。AMSデータは自動的に同期されます。',
+      howSyncWorks: '同期の仕組み',
+      syncInfo1: '公式Bambu LabスプールのみRFIDで同期されます',
+      syncInfo2: '新しいスプールは初回同期時にSpoolmanに自動作成されます',
+      syncInfo3: 'サードパーティ・詰め替えスプールはスキップされます',
+      linkingExisting: '既存スプールの連携',
+      linkingExistingDesc: '既存のSpoolmanスプールをAMSに連携するには、AMSスロットにカーソルを合わせて「Spoolmanに連携」をクリックしてください。',
+      enable: 'Spoolmanを有効化',
+      enableDesc: 'Spoolmanサーバーとフィラメントデータを同期',
+      url: 'Spoolman URL',
+      urlHint: 'SpoolmanサーバーのURL(例: http://localhost:7912)',
+      syncMode: '同期モード',
+      syncModeAuto: '自動',
+      syncModeManual: '手動のみ',
+      syncModeAutoDesc: '変更が検出されるとAMSデータが自動同期されます',
+      syncModeManualDesc: '手動トリガー時のみ同期',
+      statusLabel: 'ステータス:',
+      connected: '接続中',
+      disconnected: '未接続',
+      disconnect: '切断',
+      connect: '接続',
+      syncAmsData: 'AMSデータを同期',
+      syncAmsDataDesc: 'プリンターのAMSデータをSpoolmanに手動同期',
+      allPrinters: 'すべてのプリンター',
+      sync: '同期',
+      syncSuccess: '{{count}}個のスプールを同期しました',
+      syncWithErrors: '{{count}}個のスプールを同期({{errorCount}}件のエラー)',
+      spoolsSkipped: '{{count}}個のスプールをスキップ',
+      showAll: 'すべて表示',
+      showLess: '折りたたむ',
+      andMore: '...他{{count}}件',
+      errors: 'エラー:',
+      // LinkSpoolModal
+      linkToSpoolman: 'Spoolmanに連携',
+      linkingAmsTray: '連携するAMSトレイ:',
+      spoolUuid: 'スプールUUID:',
+      selectSpoolToLink: '連携するSpoolmanスプールを選択:',
+      unknownFilament: '不明なフィラメント',
+      noUnlinkedSpools: 'Spoolmanに未連携のスプールが見つかりません。',
+      allSpoolsLinked: 'すべてのスプールはAMSトレイに連携済みです。',
+      linking: '連携中...',
+      linkSpool: 'スプールを連携',
+      linkTooltip: 'このスプールをSpoolmanスプールに連携',
+      noUnlinked: '未連携のスプールがありません',
+    },
+
+    // Page
+    subtitle: 'Bambuddyの設定',
+    saved: '設定を保存しました',
+    saveFailed: '保存に失敗しました: {{error}}',
+
+    // Tabs
+    tabGeneral: '一般',
+    tabSmartPlugs: 'スマートプラグ',
+    tabNotifications: '通知',
+    tabFilament: 'フィラメント',
+    tabNetwork: 'ネットワーク',
+    tabApiKeys: 'APIキー',
+    tabVirtualPrinter: '仮想プリンター',
+    tabUsers: 'ユーザー',
+
+    // General - Date/Time
+    dateFormat: '日付形式',
+    timeFormat: '時刻形式',
+    systemDefault: 'システムデフォルト',
+    dateFormatUS: 'US (MM/DD/YYYY)',
+    dateFormatEU: 'EU (DD/MM/YYYY)',
+    dateFormatISO: 'ISO (YYYY-MM-DD)',
+    timeFormat12h: '12時間 (3:30 PM)',
+    timeFormat24h: '24時間 (15:30)',
+    defaultPrinter: 'デフォルトプリンター',
+    noDefaultPrinter: 'デフォルトなし(毎回選択)',
+    defaultPrinterDescription: 'アップロード、再印刷、その他の操作でこのプリンターを事前選択します。',
+    sidebarOrder: 'サイドバーの順序',
+    sidebarOrderDescription: 'サイドバーの項目をドラッグ&ドロップで並べ替えできます。ここでデフォルトの順序にリセットできます。',
+    reset: 'リセット',
+
+    // Appearance
+    darkMode: 'ダークモード',
+    lightMode: 'ライトモード',
+    active: '(有効)',
+    background: '背景',
+    accent: 'アクセント',
+    style: 'スタイル',
+    bgNeutral: 'ニュートラル',
+    bgWarm: 'ウォーム',
+    bgCool: 'クール',
+    bgOLED: 'OLED ブラック',
+    bgSlate: 'スレートブルー',
+    bgForest: 'フォレストグリーン',
+    accentGreen: 'グリーン',
+    accentTeal: 'ティール',
+    accentBlue: 'ブルー',
+    accentOrange: 'オレンジ',
+    accentPurple: 'パープル',
+    accentRed: 'レッド',
+    styleClassic: 'クラシック',
+    styleGlow: 'グロー',
+    styleVibrant: 'ビブラント',
+    themeToggleHint: 'サイドバーの太陽/月アイコンでダークモードとライトモードを切り替えます。',
+
+    // Archive Settings
+    archiveSettings: 'アーカイブ設定',
+    autoArchive: '自動アーカイブ',
+    autoArchiveDescription: '印刷完了時に3MFファイルを自動的に保存',
+    saveThumbnails: 'サムネイルを保存',
+    saveThumbnailsDescription: '3MFファイルからプレビュー画像を抽出して保存',
+    captureFinishPhoto: '完了写真を撮影',
+    captureFinishPhotoDescription: '印刷完了時にプリンターカメラから写真を撮影',
+    ffmpegNotInstalled: 'ffmpegがインストールされていません',
+    ffmpegInstruction: 'カメラキャプチャにはffmpegが必要です。brew install ffmpeg(macOS)またはapt install ffmpeg(Linux)でインストールしてください。',
+
+    // Camera
+    camera: 'カメラ',
+    cameraViewMode: 'カメラ表示モード',
+    cameraNewWindow: '新しいウィンドウ',
+    cameraEmbedded: '埋め込みオーバーレイ',
+    cameraEmbeddedDescription: 'メイン画面上のリサイズ可能なオーバーレイでカメラを開きます',
+    cameraNewWindowDescription: '別のブラウザウィンドウでカメラを開きます',
+
+    // Cost Tracking
+    costTracking: 'コスト追跡',
+    defaultFilamentCost: 'デフォルトフィラメントコスト(kg単位)',
+    currency: '通貨',
+    electricityCost: '電気代(kWh単位)',
+    energyDisplayMode: 'エネルギー表示モード',
+    energyPrintsOnly: '印刷のみ',
+    energyTotalConsumption: '総消費量',
+    energyPrintsOnlyDescription: 'ダッシュボードに印刷中に使用されたエネルギーの合計を表示',
+    energyTotalDescription: 'ダッシュボードにスマートプラグの累計エネルギーを表示',
+
+    // File Manager
+    fileManager: 'ファイルマネージャー',
+    archiveOnPrint: '印刷時のアーカイブエントリ作成',
+    archiveAlways: '常にアーカイブエントリを作成',
+    archiveNever: 'アーカイブエントリを作成しない',
+    archiveAsk: '毎回確認',
+    archiveOnPrintDescription: 'ファイルマネージャーから印刷する際、オプションでアーカイブエントリを作成',
+    lowDiskWarning: 'ディスク容量不足の警告',
+    lowDiskWarningDescription: '空きディスク容量がこのしきい値を下回ると警告を表示',
+
+    // Updates
+    checkPrinterFirmware: 'プリンターファームウェアの確認',
+    checkPrinterFirmwareDescription: 'Bambu Labのプリンターファームウェア更新を確認',
+    checkForUpdatesDescription: '起動時に自動的に新しいバージョンを確認',
+    checkNow: '今すぐ確認',
+    releaseNotes: 'リリースノート',
+    viewOnGitHub: 'GitHubで表示',
+    updateViaDocker: 'Docker Composeでアップデート:',
+    installUpdate: 'アップデートをインストール',
+    close: '閉じる',
+
+    // Data Management
+    dataManagement: 'データ管理',
+    backupData: 'データのバックアップ',
+    backupDescription: '設定、プロバイダー、プリンターなどをエクスポート',
+    export: 'エクスポート',
+    restoreBackup: 'バックアップの復元',
+    restoreDescription: '重複処理オプション付きでバックアップファイルから設定をインポート',
+    restore: '復元',
+    clearLogs: '通知ログを削除',
+    clearLogsDescription: '30日以上前の通知ログを削除',
+    clear: '削除',
+    resetUI: 'UIの設定をリセット',
+    resetUIDescription: 'サイドバーの順序、テーマ、表示モード、レイアウト設定をリセットします。プリンター、アーカイブ、設定には影響しません。',
+    clearLogsTitle: '通知ログを削除',
+    clearLogsMessage: '30日以上前のすべての通知ログを完全に削除します。この操作は元に戻せません。',
+    clearLogsConfirm: 'ログを削除',
+    clearLogsFailed: 'ログの削除に失敗しました',
+    resetUITitle: 'UIの設定をリセット',
+    resetUIMessage: 'すべてのUI設定をデフォルトにリセットします:サイドバーの順序、テーマ、ダッシュボードレイアウト、表示モード、ソート設定。プリンター、アーカイブ、サーバー設定には影響しません。クリア後にページが再読み込みされます。',
+    resetPreferences: '設定をリセット',
+    preferencesReset: 'UI設定をリセットしました。更新中...',
+
+    // Network
+    externalUrl: '外部URL',
+    externalUrlDescription: 'Bambuddyがアクセス可能な外部URL。通知画像や外部連携に使用されます。',
+    bambuddyUrl: 'Bambuddy URL',
+    externalUrlHint: 'プロトコルとポートを含めてください(例: http://192.168.1.100:8000)',
+    ftpRetry: 'FTPリトライ',
+    ftpRetryDescription: 'プリンターのWi-Fiが不安定な場合にFTP操作をリトライします。3MFダウンロード、印刷アップロード、タイムラプスダウンロード、ファームウェアアップデートに適用されます。',
+    enableRetry: 'リトライを有効化',
+    enableRetryDescription: '失敗したFTP操作を自動的にリトライ',
+    retryAttempts: 'リトライ回数',
+    times: '回',
+    retryAttemptsDescription: '諦めるまでのリトライ回数(1-10)',
+    retryDelay: 'リトライ間隔',
+    seconds: '秒',
+    retryDelayDescription: 'リトライ間の待ち時間(1-30)',
+    connectionTimeout: '接続タイムアウト',
+    connectionTimeoutDescription: '遅い接続用のソケットタイムアウト。Wi-Fiが弱いA1/A1 Miniプリンターの場合は増やしてください(10-120)',
+    homeAssistant: 'Home Assistant',
+    connected: '接続済み',
+    disconnected: '未接続',
+    homeAssistantDescription: 'Home Assistantに接続してHA REST APIでスマートプラグを制御します。switch、light、input_booleanエンティティに対応しています。',
+    enableHA: 'Home Assistantを有効化',
+    enableHADescription: 'Home Assistantでスマートプラグを制御',
+    haUrl: 'Home Assistant URL',
+    haToken: '長期アクセストークン',
+    haTokenHint: 'HAでトークンを作成: プロフィール → 長期アクセストークン → トークン作成',
+    testConnection: '接続テスト',
+    connectionSuccessful: '接続成功',
+    connectionFailed: '接続失敗',
+    haConnectionSuccess: 'Home Assistantへの接続に成功しました。',
+    haConnectionFailed: 'Home Assistantへの接続に失敗しました。',
+    mqttPublishing: 'MQTT配信',
+    mqttDescription: 'BamBuddyイベントを外部MQTTブローカーに配信し、Node-RED、Home Assistant、その他の自動化システムと連携します。',
+    enableMqtt: 'MQTTを有効化',
+    enableMqttDescription: '外部MQTTブローカーにイベントを配信',
+    brokerHostname: 'ブローカーホスト名',
+    port: 'ポート',
+    useTls: 'TLSを使用',
+    usernameOptional: 'ユーザー名(オプション)',
+    passwordOptional: 'パスワード(オプション)',
+    topicPrefix: 'トピックプレフィックス',
+    connectedTo: '接続先:',
+    notConnected: '未接続',
+
+    // Smart Plugs
+    smartPlugsDescription: 'スマートプラグ(TasmotaまたはHome Assistant)を接続して、電源制御を自動化し、プリンターのエネルギー消費を追跡します。',
+    turnAllPlugsOn: 'すべてのプラグをオン',
+    turnAllPlugsOff: 'すべてのプラグをオフ',
+    allOn: '全オン',
+    allOff: '全オフ',
+    addSmartPlug: 'スマートプラグを追加',
+    energySummary: 'エネルギー概要',
+    currentPower: '現在の電力',
+    today: '今日',
+    yesterday: '昨日',
+    total: '合計',
+    enablePlugsForEnergy: 'プラグを有効にしてエネルギー概要を表示',
+    noSmartPlugs: 'スマートプラグが設定されていません',
+    noSmartPlugsDescription: 'Tasmotaベースのスマートプラグを追加して、エネルギー消費を追跡し、電源制御を自動化します。',
+    addFirstSmartPlug: '最初のスマートプラグを追加',
+
+    // Notifications
+    providers: 'プロバイダー',
+    log: 'ログ',
+    testAll: 'すべてテスト',
+    add: '追加',
+    testResults: 'テスト結果',
+    dismiss: '閉じる',
+    noProviders: 'プロバイダーが設定されていません',
+    noProvidersDescription: 'アラートを受信するにはプロバイダーを追加してください。',
+    messageTemplates: 'メッセージテンプレート',
+    templatesDescription: '各イベントの通知メッセージをカスタマイズします。',
+    noTemplates: 'テンプレートがありません。バックエンドを再起動してデフォルトテンプレートを作成してください。',
+    testAllSuccess: 'すべて{{count}}件のプロバイダーのテストに成功しました!',
+    testAllPartial: '{{success}}/{{tested}}件のプロバイダーが成功',
+    testProvidersFailed: 'プロバイダーのテストに失敗しました: {{error}}',
+    bulkPlugSuccess: 'すべて{{count}}個のプラグを{{action}}にしました',
+    bulkPlugPartial: '{{success}}個のプラグを{{action}}にしました、{{failed}}個が失敗',
+    bulkPlugFailed: '失敗: {{error}}',
+
+    // API Keys
+    apiKeys: 'APIキー',
+    apiKeysDescription: '外部連携やWebhook用のAPIキーを作成します。',
+    createKey: 'キーを作成',
+    apiKeyCreated: 'APIキーを作成しました',
+    apiKeyDeleted: 'APIキーを削除しました',
+    apiKeySuccess: 'APIキーが正常に作成されました',
+    apiKeyCopyWarning: '今すぐこのキーをコピーしてください - 再表示されません!',
+    keyCopied: 'キーをクリップボードにコピーしました',
+    keyCopyFailed: 'キーのコピーに失敗しました',
+    keyAddedToBrowser: 'キーをAPIブラウザに追加しました',
+    useInBrowser: 'APIブラウザで使用',
+    createNewApiKey: '新しいAPIキーを作成',
+    keyName: 'キー名',
+    permissions: '権限',
+    readStatus: 'ステータスの読み取り',
+    readStatusDescription: 'プリンターのステータスとキューを表示',
+    manageQueue: 'キューの管理',
+    manageQueueDescription: '印刷キューへのアイテムの追加と削除',
+    controlPrinter: 'プリンターの制御',
+    controlPrinterDescription: '印刷の一時停止、再開、停止',
+    cancel: 'キャンセル',
+    lastUsed: '最終使用:',
+    badgeRead: '読取',
+    badgeQueue: 'キュー',
+    badgeControl: '制御',
+    noApiKeys: 'APIキーがありません',
+    noApiKeysDescription: '外部サービスとの連携用にAPIキーを作成してください。',
+    createFirstKey: '最初のキーを作成',
+    deleteApiKeyTitle: 'APIキーを削除',
+    deleteApiKeyMessage: 'このAPIキーを削除してもよろしいですか?このキーを使用しているすべての連携が動作しなくなります。',
+    deleteKey: 'キーを削除',
+    webhookEndpoints: 'Webhookエンドポイント',
+    webhookApiKeyHint: 'X-API-KeyヘッダーでAPIキーを使用してください。',
+    webhookGetAll: 'すべてのプリンターステータスを取得',
+    webhookGetOne: '特定のプリンターステータスを取得',
+    webhookQueue: '印刷キューに追加',
+    webhookPause: '印刷を一時停止',
+    webhookResume: '印刷を再開',
+    webhookStop: '印刷を停止',
+    apiBrowser: 'APIブラウザ',
+    apiBrowserDescription: 'すべての利用可能なAPIエンドポイントを探索してテストします。',
+    apiKeyForTesting: 'テスト用APIキー',
+    apiKeyHeaderHint: 'このキーはリクエストのX-API-Keyヘッダーとして送信されます。',
+    apiKeyCreateFailed: 'APIキーの作成に失敗しました: {{error}}',
+    apiKeyDeleteFailed: 'APIキーの削除に失敗しました: {{error}}',
+
+    // Filament
+    amsThresholds: 'AMS表示しきい値',
+    amsThresholdsDescription: 'AMS湿度と温度インジケーターの色しきい値を設定します。',
+    humidity: '湿度',
+    goodGreen: '良好(緑)≤',
+    fairOrange: '普通(オレンジ)≤',
+    aboveFairBad: '普通のしきい値以上は赤(悪い)で表示',
+    temperature: '温度',
+    goodBlue: '良好(青)≤',
+    aboveFairHot: '普通のしきい値以上は赤(高温)で表示',
+    historyRetention: '履歴の保持',
+    keepHistoryFor: 'センサー履歴の保持期間',
+    days: '日',
+    historyRetentionDescription: '古い湿度と温度データは自動的に削除されます',
+    printModal: '印刷ダイアログ',
+    expandMapping: 'カスタムマッピングをデフォルトで展開',
+    expandMappingDescription: '複数のプリンターに印刷する際、プリンターごとのAMSマッピングを展開して表示',
+
+    // Users
+    currentUser: '現在のユーザー',
+    role: 'ロール:',
+    roleAdmin: '管理者',
+    roleUser: 'ユーザー',
+    manageUsers: 'ユーザー管理',
+    disableAuth: '認証を無効化',
+    rolePermissions: 'ロール権限',
+    rolePermissionsDescription: '各ロールの権限の概要。',
+    adminPerm1: 'プリンター設定の管理',
+    adminPerm2: 'ユーザーの作成、編集、削除',
+    adminPerm3: 'すべてのシステム機能へのアクセス',
+    userPerm1: '印刷ジョブの送信',
+    userPerm2: 'ファイルとアーカイブの管理',
+    userPerm3: 'フィラメントの管理',
+    disableAuthTitle: '認証を無効化',
+    disableAuthMessage: '認証を無効にしてもよろしいですか?これにより、Bambuddyインスタンスはログインなしでアクセス可能になります。すべてのユーザーはデータベースに残りますが、認証は無効になります。',
+    disableAuthConfirm: '認証を無効化',
+    authDisabledSuccess: '認証を正常に無効化しました',
+    disableAuthFailed: '認証の無効化に失敗しました',
+    unknownError: '不明なエラー',
+    haUrlPlaceholder: 'http://homeassistant.local:8123',
+    haTokenPlaceholder: '長期アクセストークン',
+    mqttHostPlaceholder: '例: 192.168.1.100',
+    leaveEmptyAnonymous: '匿名接続の場合は空欄',
+    mqttTopicDefault: 'bambuddy',
+    apiKeyNamePlaceholder: '例: Home Assistantインテグレーション',
+    apiKeyTestPlaceholder: 'テスト用APIキーを貼り付け',
+    unnamedKey: '名前なしキー',
+    plugsOnline: '{{online}}/{{total}} オンライン',
+    passed: '{{count}}件 成功',
+    failed: '{{count}}件 失敗',
+    externalUrlPlaceholder: 'http://192.168.1.100:8000',
+    bulkPlugTitle: 'すべてのプラグを{{action}}',
+    bulkPlugMessage: '有効な{{count}}個のスマートプラグをすべて{{action}}しますか?',
+    bulkPlugConfirm: 'すべて{{action}}',
+    updateCheckFailed: '確認失敗: {{error}}',
+    releaseNotesTitle: 'リリースノート - v{{version}}',
+    mqttTopicsHint: 'トピック: {{prefix}}/printers/<serial>/status など',
+    virtualPrinterUpdated: '仮想プリンター設定を更新しました',
+    vp: {
+      title: '仮想プリンター',
+      running: '稼働中',
+      stopped: '停止中',
+      description: 'Bambu StudioやOrcaSlicerに表示される仮想プリンターを有効にします。このプリンターに送信されたファイルは印刷せずに直接アーカイブされます。',
+      enable: '仮想プリンターを有効化',
+      visibleInSlicer: 'スライサーの検出リストに「Bambuddy」として表示',
+      notVisible: 'スライサーに表示されません',
+      printerModel: 'プリンターモデル',
+      printerModelDescription: 'エミュレートするプリンターモデルを選択します。',
+      modelRestartWarning: 'モデルを変更すると仮想プリンターが再起動されます',
+      accessCode: 'アクセスコード',
+      accessCodeSet: 'アクセスコードが設定済み',
+      noAccessCode: 'アクセスコード未設定 - 有効化に必要です',
+      accessCodeRequired: '先にアクセスコードを設定してください',
+      accessCodeEmpty: 'アクセスコードは空にできません',
+      accessCodeLength: 'アクセスコードは8文字である必要があります',
+      accessCodeHint: '8文字である必要があります。スライサーの認証に使用されます。',
+      mode: 'モード',
+      modeArchive: 'アーカイブ',
+      modeArchiveDesc: 'ファイルを即座にアーカイブ',
+      modeReview: 'レビュー',
+      modeReviewDesc: 'アーカイブ前にレビューとタグ付け',
+      modeQueue: 'キュー',
+      modeQueueDesc: 'アーカイブしてキューに追加',
+      setupRequired: 'セットアップが必要',
+      setupDescription: '仮想プリンター機能を使用するには、追加のシステム設定が必要です。ポートフォワーディング、ファイアウォールルール、プラットフォーム固有の設定が含まれます。',
+      readSetupGuide: '有効化する前にセットアップガイドをお読みください',
+      howItWorks: '仕組み:',
+      step1: 'お使いのプラットフォームのセットアップガイドを完了する',
+      step2: '仮想プリンターを有効にしてアクセスコードを設定する',
+      step3: 'Bambu StudioまたはOrcaSlicerで「プリンターを追加」を開く',
+      step4: '検出リストに「Bambuddy」プリンターが表示される',
+      step5: '設定したアクセスコードで接続する',
+      step6: 'Bambuddyに「印刷」すると、3MFファイルがアーカイブされる',
+      statusDetails: 'ステータス詳細',
+      printerName: 'プリンター名',
+      model: 'モデル',
+      serialNumber: 'シリアルナンバー',
+      pendingFiles: '保留中のファイル',
+    },
+    enterNewCodeToChange: '新しいコードを入力して変更',
+    enter8CharCode: '8文字のコードを入力',
+    notificationTitlePlaceholder: '通知タイトル...',
+    notificationBodyPlaceholder: '通知本文...',
+    templateTitleRequired: 'タイトルは必須です',
+    templateBodyRequired: '本文は必須です',
+    editTemplate: 'テンプレートを編集: {{name}}',
+    templateTitle: 'タイトル',
+    templateBody: '本文',
+    availableVariables: '利用可能な変数',
+    insertVariableHint: 'クリックして本文のカーソル位置に挿入',
+    livePreview: 'ライブプレビュー',
+    loadingPreview: 'プレビューを読み込み中...',
+    previewTitle: 'タイトル:',
+    previewBody: '本文:',
+    enterTemplateToPreview: 'テンプレートの内容を入力するとプレビューが表示されます',
+    resetToDefault: 'デフォルトに戻す',
+    noExternalLinks: '外部リンクが設定されていません',
+    clickAddLink: '「リンクを追加」をクリックして追加',
+    deleteLink: 'リンクを削除',
+    deleteLinkConfirm: '「{{name}}」を削除しますか?この操作は取り消せません。',
+    editLink: 'リンクを編集',
+    addExternalLink: '外部リンクを追加',
+    addLink: 'リンクを追加',
+    sidebarLinks: 'サイドバーリンク',
+    sidebarLinksDescription: '外部リンクをサイドバーナビゲーションに追加します。ドラッグで並べ替え。',
+    linkNamePlaceholder: 'マイリンク',
+    linkNameLabel: '名前',
+    linkUrlLabel: 'URL',
+    linkIconLabel: 'アイコン',
+    linkCustomIcon: 'カスタムアイコン',
+    linkCustomIconHint: 'PNG, JPG, GIF, SVG, WebP, ICO。最大1MB。',
+    linkChoosePresetIcon: 'またはプリセットアイコンを選択',
+    linkNameRequired: '名前は必須です',
+    linkUrlRequired: 'URLは必須です',
+    linkUrlInvalid: 'URLは http:// または https:// で始まる必要があります',
+    linkInvalidImageType: '有効な画像ファイルを選択してください(PNG, JPG, GIF, SVG, WebP, ICO)',
+    linkImageTooLarge: '画像ファイルは1MB以下にしてください',
+    removeCustomIcon: 'カスタムアイコンを削除',
+    jsonRequestBody: 'JSONリクエストボディ...',
+    searchEndpoints: 'エンドポイントを検索...',
+    // Groups and Users
+    groups: 'グループ',
+    addGroup: 'グループを追加',
+    noGroupsFound: 'グループが見つかりません',
+    systemGroup: 'システム',
+    noDescription: '説明なし',
+    users: 'ユーザー',
+    addUser: 'ユーザーを追加',
+    noUsersFound: 'ユーザーが見つかりません',
+    admin: '管理者',
+    authentication: '認証',
+    authEnableDescription: '認証を有効にして、ユーザーアカウントの作成、権限の管理、Bambuddyインスタンスのセキュリティ保護を行います。',
+    authSecuredDescription: 'Bambuddyインスタンスは認証で保護されています。',
+    enable: '有効にする',
+    disable: '無効にする',
+    changePassword: 'パスワードを変更',
+    backup: 'バックアップ',
+    backupAndRestore: 'バックアップと復元',
+    backupAndRestoreDescription: 'Bambuddyデータのバックアップを作成および復元します',
+    goToBackup: 'バックアップへ移動',
+    homeAssistantDescriptionFull: 'Home Assistantに接続してスマートホームデバイスを制御します',
+    usersCount: '{{count}}人のユーザー',
+    permissionsCount: '{{count}}個の権限',
+
+    // Users tab - Auth Disabled
+    authDisabledTitle: '認証が無効です',
+    authDisabledDescription: '認証を有効にして、ユーザーアカウントの作成、権限の管理、Bambuddyインスタンスのセキュリティを確保しましょう。',
+    authFeature1: 'システムへのアクセスにログインを要求',
+    authFeature2: 'グループベースの権限を持つ複数のユーザーを作成',
+    authFeature3: '50以上のきめ細かい権限でアクセスを制御',
+    enableAuthentication: '認証を有効にする',
+
+    // Users tab - Create User Modal
+    createUser: 'ユーザーを作成',
+    username: 'ユーザー名',
+    enterUsername: 'ユーザー名を入力',
+    password: 'パスワード',
+    enterPassword: 'パスワードを入力(6文字以上)',
+    confirmPassword: 'パスワードの確認',
+    confirmPasswordPlaceholder: 'パスワードを確認',
+    passwordsDoNotMatch: 'パスワードが一致しません',
+    groupsLabel: 'グループ',
+    systemLabel: '(システム)',
+    noGroupsAvailable: '利用可能なグループがありません',
+    creating: '作成中...',
+
+    // Users tab - Edit User Modal
+    editUser: 'ユーザーを編集',
+    leaveBlankToKeep: '(現在のパスワードを維持する場合は空白)',
+    enterNewPassword: '新しいパスワードを入力',
+    confirmNewPassword: '新しいパスワードを確認',
+
+    // Users tab - Delete User
+    deleteUser: 'ユーザーを削除',
+    deleteUserConfirm: 'このユーザーを削除してもよろしいですか?この操作は元に戻せません。',
+
+    // Users tab - Create/Edit Group Modal
+    createGroup: 'グループを作成',
+    editGroup: 'グループを編集',
+    groupName: 'グループ名',
+    enterGroupName: 'グループ名を入力',
+    systemGroupNameReadonly: 'システムグループ名は変更できません',
+    descriptionLabel: '説明',
+    enterDescription: '説明を入力(任意)',
+    permissionsSelected: '権限({{count}}件選択中)',
+
+    // Users tab - Delete Group
+    deleteGroup: 'グループを削除',
+    deleteGroupConfirm: 'このグループを削除してもよろしいですか?このグループのユーザーはこれらの権限を失います。',
+
+    // Users tab - Change Password Modal
+    changePasswordTitle: 'パスワードの変更',
+    currentPassword: '現在のパスワード',
+    enterCurrentPassword: '現在のパスワードを入力',
+    newPassword: '新しいパスワード',
+    enterNewPasswordMin6: '新しいパスワードを入力(6文字以上)',
+    confirmNewPasswordPlaceholder: '新しいパスワードを確認',
+    passwordMinLength: 'パスワードは6文字以上である必要があります',
+    passwordChanged: 'パスワードが正常に変更されました',
+    failedToChangePassword: 'パスワードの変更に失敗しました',
+    changingPassword: '変更中...',
+
+    // Prometheus Metrics
+    prometheusTitle: 'Prometheusメトリクス',
+    prometheusDescriptionPre: 'プリンターメトリクスを',
+    prometheusDescriptionPost: 'でPrometheus/Grafanaモニタリング用に公開します。',
+    enableMetricsEndpoint: 'メトリクスエンドポイントを有効化',
+    exposePrometheusData: 'プリンターデータをPrometheus形式で公開',
+    bearerTokenOptional: 'Bearerトークン(任意)',
+    bearerTokenPlaceholder: '認証不要の場合は空欄のまま',
+    bearerTokenHint: '設定した場合、リクエストに次のヘッダーが必要: ',
+    availableMetrics: '利用可能なメトリクス',
+    metricConnectionStatus: '接続状態',
+    metricPrinterState: 'プリンター状態(アイドル/印刷中など)',
+    metricPrintProgress: '印刷進捗 0-100%',
+    metricBedTemp: 'ベッド温度',
+    metricNozzleTemp: 'ノズル温度',
+    metricPrintsTotal: '結果別の合計印刷数',
+    metricAndMore: '...その他(レイヤー、ファン、キュー、フィラメント使用量)',
+  },
+
+  // Notifications (for push notifications)
+  notification: {
+    printStarted: {
+      title: '印刷開始',
+      body: '{{printer}}: {{filename}} の印刷を開始しました',
+    },
+    printCompleted: {
+      title: '印刷完了',
+      body: '{{printer}}: {{filename}} が正常に完了しました',
+    },
+    printFailed: {
+      title: '印刷失敗',
+      body: '{{printer}}: {{filename}} が失敗しました',
+    },
+    printStopped: {
+      title: '印刷中止',
+      body: '{{printer}}: {{filename}} が中止されました',
+    },
+    printProgress: {
+      title: '印刷進捗',
+      body: '{{printer}}: {{filename}} は {{percent}}% 完了',
+    },
+    printerOffline: {
+      title: 'プリンターオフライン',
+      body: '{{printer}} がオフラインです',
+    },
+    printerError: {
+      title: 'プリンターエラー',
+      body: '{{printer}}: {{error}}',
+    },
+    filamentLow: {
+      title: 'フィラメント残量低下',
+      body: '{{printer}}: フィラメントが残りわずかです',
+    },
+    maintenanceDue: {
+      title: 'メンテナンス期限',
+      body: '{{printer}}: {{items}} の対応が必要です',
+    },
+  },
+
+  // Errors
+  errors: {
+    generic: '問題が発生しました',
+    networkError: 'ネットワークエラーです。接続を確認してください。',
+    notFound: '見つかりません',
+    unauthorized: '認証エラー',
+    serverError: 'サーバーエラー',
+    validationError: '入力内容を確認してください',
+    printerConnectionFailed: 'プリンターへの接続に失敗しました',
+    saveFailed: '保存に失敗しました',
+    deleteFailed: '削除に失敗しました',
+    loadFailed: 'データの読み込みに失敗しました',
+  },
+
+  // Confirmations
+  confirm: {
+    delete: '削除しますか?',
+    unsavedChanges: '保存されていない変更があります。このページを離れますか?',
+    clearQueue: 'キューをクリアしますか?',
+  },
+
+  // Calendar
+  calendar: {
+    months: {
+      january: '1月', february: '2月', march: '3月',
+      april: '4月', may: '5月', june: '6月',
+      july: '7月', august: '8月', september: '9月',
+      october: '10月', november: '11月', december: '12月',
+    },
+    daysShort: {
+      sun: '日', mon: '月', tue: '火', wed: '水',
+      thu: '木', fri: '金', sat: '土',
+    },
+  },
+
+  // 通知ログ
+  notificationLog: {
+    title: '通知ログ',
+    lastDays: '過去{{days}}日間:',
+    notifications: '件の通知',
+    sent: '送信済み',
+    failed: '失敗',
+    last24Hours: '過去24時間',
+    showFailedOnly: '失敗のみ表示',
+    clearOld: '古いログを削除',
+    noFailedNotifications: '失敗した通知はありません',
+    noNotificationsLogged: '通知ログはありません',
+    unknownProvider: '不明なプロバイダー',
+    titleLabel: 'タイトル',
+    messageLabel: 'メッセージ',
+    errorLabel: 'エラー',
+    provider: 'プロバイダー:',
+    time: '時間:',
+    clearLogsFailed: 'ログの削除に失敗しました: {{error}}',
+    events: {
+      print_start: '印刷開始',
+      print_complete: '印刷完了',
+      print_failed: '印刷失敗',
+      print_stopped: '印刷中止',
+      print_progress: '進捗',
+      printer_offline: 'プリンターオフライン',
+      printer_error: 'プリンターエラー',
+      filament_low: 'フィラメント残量低下',
+      maintenance_due: 'メンテナンス期限',
+      ams_humidity_high: 'AMS湿度高',
+      ams_temperature_high: 'AMS温度高',
+      test: 'テスト',
+    },
+  },
+
+  // Notification providers
+  providers: {
+    callmebot: 'CallMeBot/WhatsApp',
+    ntfy: 'ntfy',
+    pushover: 'Pushover',
+    telegram: 'Telegram',
+    email: 'メール',
+    discord: 'Discord',
+    webhook: 'Webhook',
+    descriptions: {
+      email: 'SMTPメール通知',
+      telegram: 'Telegramボット経由の通知',
+      discord: 'Webhook経由でDiscordチャンネルに送信',
+      ntfy: '無料のセルフホスト対応プッシュ通知',
+      pushover: 'シンプルで確実なプッシュ通知',
+      callmebot: 'CallMeBot経由の無料WhatsApp通知',
+      webhook: '任意のURLへの汎用HTTP POST',
+    },
+  },
+
+  // Backup / Restore
+  backup: {
+    categories: {
+      settings: '設定',
+      notification_providers: '通知プロバイダー',
+      notification_templates: '通知テンプレート',
+      smart_plugs: 'スマートプラグ',
+      printers: 'プリンター',
+      filaments: 'フィラメント',
+      maintenance_types: 'メンテナンスタイプ',
+      archives: 'アーカイブ',
+      projects: 'プロジェクト',
+      pending_uploads: 'アップロード待ち',
+      external_links: '外部リンク',
+      api_keys: 'APIキー',
+    },
+    notificationProviders: '通知プロバイダー',
+    notificationTemplates: '通知テンプレート',
+    externalLinks: '外部リンク',
+    printers: 'プリンター',
+    filamentInventory: 'フィラメント在庫',
+    maintenanceTypes: 'メンテナンスタイプ',
+    printArchives: '印刷アーカイブ',
+    projects: 'プロジェクト',
+    pendingUploads: '保留中のアップロード',
+    apiKeys: 'APIキー',
+    settingsDescription: '言語、テーマ、更新設定',
+    notificationProvidersDescription: 'ntfy、Pushover、Discordなど',
+    notificationTemplatesDescription: 'カスタムメッセージテンプレート',
+    smartPlugsDescription: 'Tasmotaプラグ設定',
+    externalLinksDescription: '外部サービスへのサイドバーリンク',
+    printersDescription: 'プリンター情報(アクセスコードを除く)',
+    filamentInventoryDescription: 'フィラメントの種類とコスト',
+    maintenanceDescription: 'カスタムメンテナンススケジュール',
+    printArchivesDescription: '全印刷データ+ファイル(3MF、サムネイル、写真)',
+    projectsDescription: 'プロジェクト、BOMアイテム、添付ファイル',
+    pendingUploadsDescription: 'レビュー待ちの仮想プリンターアップロード',
+    apiKeysDescription: 'Webhook APIキー(インポート時に新しいキーが生成されます)',
+    restoreBackup: 'バックアップの復元',
+    restoreDescription: 'バックアップファイルからすべてのデータを置き換える',
+    restoreNote: '復元中、仮想プリンターは停止されます',
+  },
+
+  // Restore modal
+  restore: {
+    failedToRestore: 'バックアップの復元に失敗しました。ファイル形式を確認してください。',
+    title: 'バックアップの復元',
+    restoring: '復元中...',
+    complete: '復元完了',
+    failed: '復元失敗',
+    importDescription: 'バックアップファイルから設定をインポート',
+    pleaseWait: 'データを復元しています。しばらくお待ちください',
+    duplicateHandling: '重複処理の仕組み:',
+    clickToSelect: 'クリックしてバックアップファイルを選択 (.json または .zip)',
+    replaceExisting: '既存データを上書き',
+    keepExisting: '既存データを保持',
+    overwriteDescription: '既に存在する項目をバックアップデータで上書きします',
+    skipDescription: 'まだ存在しない項目のみ復元します',
+    caution: '注意:',
+    overwriteWarning: '上書きすると、現在の設定がバックアップのデータに置き換えられます。セキュリティのため、プリンターのアクセスコードは上書きされません。',
+    restore: '復元',
+    processing: 'バックアップファイルを処理中...',
+    itemsRestored: '復元済み項目',
+    itemsSkipped: 'スキップ済み項目',
+    restoredSection: '復元済み',
+    filesSection: 'ファイル (3MF、サムネイルなど)',
+    skippedSection: 'スキップ (既に存在)',
+    andMore: '...他 {{count}} 件',
+    newApiKeys: '新しいAPIキーが生成されました',
+    copyKeysWarning: 'これらのキーは一度だけ表示されます。今すぐコピーしてください!',
+    copy: 'コピー',
+    noDataFound: 'バックアップファイルに復元するデータが見つかりませんでした。',
+    dupPrinters: 'プリンター',
+    dupPrintersDesc: 'シリアル番号で照合',
+    dupSmartPlugs: 'スマートプラグ',
+    dupSmartPlugsDesc: 'IPアドレスで照合',
+    dupNotificationProviders: '通知プロバイダー',
+    dupNotificationProvidersDesc: '名前で照合',
+    dupFilaments: 'フィラメント',
+    dupFilamentsDesc: '名前 + 種類 + ブランドで照合',
+    dupArchives: 'アーカイブ',
+    dupArchivesDesc: 'コンテンツハッシュで照合 (常にスキップ)',
+    dupPendingUploads: 'アップロード待ち',
+    dupPendingUploadsDesc: 'ファイル名で照合',
+    dupSettingsTemplates: '設定とテンプレート',
+    dupSettingsTemplatesDesc: '常に上書き',
+  },
+
+  // File manager
+  files: {
+    root: 'ルート',
+    cache: 'キャッシュ',
+    models: 'モデル',
+    timelapse: 'タイムラプス',
+    used: '使用中:',
+    free: '空き:',
+    filterPlaceholder: 'ファイルを検索...',
+    noFiles: 'このディレクトリにファイルがありません',
+    deleteFile: 'ファイルを削除',
+    deleteConfirm: '"{{name}}"を削除しますか?この操作は元に戻せません。',
+    // ファイル管理ページ
+    fileManager: 'ファイル管理',
+    pageDescription: 'プリントファイルを整理・管理',
+    newFolder: '新しいフォルダ',
+    folderName: 'フォルダ名',
+    folderNamePlaceholder: '例: 機能パーツ',
+    renameFile: 'ファイル名を変更',
+    renameFolder: 'フォルダ名を変更',
+    rename: '名前変更',
+    rootNoFolder: 'ルート(フォルダなし)',
+    moveFilesCount: '{{count}}個のファイルを移動',
+    current: '(現在)',
+    move: '移動',
+    linkFolder: 'フォルダをリンク',
+    linkFolderDescription: '「{{name}}」をプロジェクトまたはアーカイブにリンクしてすばやくアクセス。',
+    noProjectsFound: 'プロジェクトが見つかりません',
+    noArchivesFound: 'アーカイブが見つかりません',
+    unlink: 'リンク解除',
+    link: 'リンク',
+    uploadFiles: 'ファイルをアップロード',
+    dropFilesHere: 'ここにファイルをドロップ',
+    dragAndDropFiles: 'ファイルをドラッグ&ドロップ',
+    orClickToBrowse: 'またはクリックして選択',
+    zipAutoExtract: 'ZIPファイルは自動的に展開されます',
+    zipFilesDetected: 'ZIPファイルを検出',
+    zipChooseHandling: 'ZIPファイルが展開されます。フォルダ構造の処理方法を選択してください:',
+    preserveZipStructure: 'ZIPのフォルダ構造を保持',
+    willBeExtracted: '• 展開予定',
+    filesExtracted: '• {{count}}個のファイルを展開済み',
+    uploadComplete: 'アップロード完了: {{count}}個成功',
+    uploadErrorCount: '、{{count}}個失敗',
+    uploading: 'アップロード中...',
+    filesFailed: '{{count}}個のファイルが失敗',
+    uploadFailed: 'アップロード失敗',
+    linkToProjectOrArchive: 'プロジェクトまたはアーカイブにリンク',
+    changeLink: 'リンクを変更...',
+    linkTo: 'リンク先...',
+    printedCount: '{{count}}回印刷済み',
+    addToQueue: 'キューに追加',
+    adding: '追加中...',
+    folderCreated: 'フォルダを作成しました',
+    folderDeleted: 'フォルダを削除しました',
+    fileDeleted: 'ファイルを削除しました',
+    deletedFiles: '{{count}}個のファイルを削除しました',
+    filesMoved: 'ファイルを移動しました',
+    folderUnlinked: 'フォルダのリンクを解除しました',
+    folderLinked: 'フォルダをリンクしました',
+    addedToQueue: '{{count}}個のファイルをキューに追加しました',
+    addedToQueueWithErrors: '{{added}}個追加、{{errors}}個失敗',
+    failedToAddFiles: 'ファイルの追加に失敗: {{error}}',
+    fileRenamed: 'ファイル名を変更しました',
+    folderRenamed: 'フォルダ名を変更しました',
+    gridView: 'グリッド表示',
+    listView: 'リスト表示',
+    lowDiskSpaceWarning: 'ディスク容量不足の警告',
+    lowDiskSpaceDescription: '空き容量は{{free}}(合計{{total}})。しきい値は設定で{{threshold}} GBに設定されています。',
+    filesLabel: 'ファイル:',
+    foldersLabel: 'フォルダ:',
+    sizeLabel: 'サイズ:',
+    freeLabel: '空き:',
+    folders: 'フォルダ',
+    allFiles: 'すべてのファイル',
+    allTypes: 'すべての種類',
+    date: '日付',
+    size: 'サイズ',
+    type: '種類',
+    prints: '印刷回数',
+    ascending: '昇順',
+    descending: '降順',
+    filteredCount: '{{total}}件中{{filtered}}件',
+    deselectAll: 'すべて選択解除',
+    selectAll: 'すべて選択',
+    selectedCount: '{{count}}件選択中',
+    loadingFiles: 'ファイルを読み込み中...',
+    folderEmpty: 'フォルダは空です',
+    noFilesYet: 'ファイルはまだありません',
+    emptyFolderDescription: 'ファイルをアップロードするか、このフォルダにファイルを移動してください。',
+    emptyRootDescription: 'プリント関連ファイルの整理を始めるにはファイルをアップロードしてください。',
+    noMatchingFiles: '一致するファイルがありません',
+    noMatchingFilesDescription: '現在の検索またはフィルター条件に一致するファイルがありません。',
+    clearFilters: 'フィルターをクリア',
+    sort: {
+      nameAsc: '名前 (A-Z)',
+      nameDesc: '名前 (Z-A)',
+      sizeAsc: 'サイズ (小さい順)',
+      sizeDesc: 'サイズ (大きい順)',
+      dateAsc: '日付 (古い順)',
+      dateDesc: '日付 (新しい順)',
+    },
+    searchFiles: 'ファイルを検索...',
+    deleteFolder: 'フォルダを削除',
+    deleteBulkTitle: '{{count}}件のファイルを削除',
+    deleteFolderConfirm: 'このフォルダを削除しますか?中のファイルもすべて削除されます。',
+    deleteMultipleConfirm: '選択した{{count}}件のファイルを削除しますか?この操作は元に戻せません。',
+    deleteFileConfirm: 'このファイルを削除しますか?',
+    deleting: '削除中...',
+  },
+
+  // Print options
+  printOptions: {
+    title: '印刷オプション',
+    bedLevelling: 'ベッドレベリング',
+    bedLevellingDesc: '印刷前にベッドを自動レベリング',
+    flowCali: 'フローキャリブレーション',
+    flowCaliDesc: '押出量を校正',
+    vibrationCali: '振動キャリブレーション',
+    vibrationCaliDesc: 'リンギングを低減',
+    layerInspect: '初層検査',
+    layerInspectDesc: 'AIによる初層の検査',
+    timelapse: 'タイムラプス',
+    timelapseDesc: 'タイムラプス動画を記録',
+  },
+
+  // 印刷モーダル
+  printModal: {
+    powerOffWhenDone: '完了後にプリンターの電源をオフ',
+    immediateHelp: 'プリンターがアイドル状態になり次第、印刷を開始します。',
+    scheduledHelp: 'スケジュールされた時間にプリンターがアイドル状態であれば印刷を開始します。ビジー状態の場合は、利用可能になるまで待機します。',
+    stagedHelp: '印刷はステージングされますが、自動的には開始されません。キューに送るにはスタートボタンを使用してください。',
+    print: '印刷',
+    reprint: '再印刷',
+    printToMultiple: '{{count}}台のプリンターに印刷',
+    editQueueItem: 'キューアイテムを編集',
+    sameTypeDifferentColor: '同じタイプ、異なる色',
+    filamentTypeNotLoaded: 'フィラメントタイプが読み込まれていません',
+    specificPrinter: '特定のプリンター',
+    selectPrinters: 'プリンターを選択',
+    selectAll: 'すべて選択',
+    printersSelected: '{{count}}台のプリンターを選択中',
+    noPrintersAvailable: '利用可能なプリンターがありません',
+    noActivePrintersAvailable: 'アクティブなプリンターがありません',
+    inactive: '非アクティブ',
+    unknownModel: '不明なモデル',
+    customMapping: 'カスタムマッピング',
+    auto: '自動',
+    showAll: 'すべて表示',
+    selectAtLeastOne: 'プリンターを1台以上選択してください',
+    selectTargetModel: 'ターゲットモデルを選択',
+    selectSlot: 'スロットを選択...',
+    reRead: '再読み込み',
+    customSlotMapping: 'カスタムスロットマッピング',
+    autoMatched: '自動マッチ',
+    manuallySelected: '手動選択',
+    schedulerWillAssign: 'スケジューラーが最初に利用可能なアイドル状態の{{model}}プリンターに割り当てます',
+    inLocation: '{{location}}内',
+    anyModel: '任意の{{model}}',
+    hiddenPrinters: '他{{count}}台のプリンターが非表示(異なるモデル)',
+    showOnlyModel: '{{model}}プリンターのみ表示',
+    matchedCount: '({{matched}}/{{total}} 一致)',
+    targetModelLabel: 'ターゲットモデル',
+    selectAModel: 'モデルを選択...',
+    locationFilterLabel: 'ロケーションフィルター(任意)',
+    anyLocation: 'すべてのロケーション',
+  },
+
+  // ログインページ
+  login: {
+    title: 'Bambuddy ログイン',
+    subtitle: 'アカウントにサインイン',
+    username: 'ユーザー名',
+    usernamePlaceholder: 'ユーザー名を入力',
+    password: 'パスワード',
+    passwordPlaceholder: 'パスワードを入力',
+    loggingIn: 'ログイン中...',
+    signIn: 'サインイン',
+    enterCredentials: 'ユーザー名とパスワードを入力してください',
+    success: 'ログインしました',
+    failed: 'ログインに失敗しました',
+  },
+
+  // セットアップページ
+  setup: {
+    title: 'Bambuddy セットアップ',
+    description: 'Bambuddyインスタンスの認証を設定します',
+    enableAuthentication: '認証を有効にする',
+    adminAccount: '管理者アカウント',
+    adminAccountDescription: '既に管理者ユーザーが存在する場合、既存の管理者アカウントを使用して認証が有効になります。既存の管理者を使用するには以下のフィールドを空のままにするか、新しい管理者ユーザーを作成するには新しい認証情報を入力してください。',
+    adminUsername: '管理者ユーザー名',
+    adminPassword: '管理者パスワード',
+    optionalIfAdminsExist: '管理者ユーザーが存在する場合は任意',
+    placeholderAdminUsername: '管理者ユーザー名を入力(任意)',
+    placeholderAdminPassword: '管理者パスワードを入力(任意)',
+    confirmPassword: 'パスワードの確認',
+    placeholderConfirmPassword: '管理者パスワードを確認',
+    settingUp: 'セットアップ中...',
+    completeSetup: 'セットアップを完了',
+    toast: {
+      authEnabledAdminCreated: '認証が有効になり、管理者ユーザーが作成されました',
+      authEnabledExistingAdmins: '既存の管理者ユーザーを使用して認証が有効になりました',
+      setupCompleted: 'セットアップが完了しました',
+      enterBothOrLeaveEmpty: '管理者ユーザー名とパスワードの両方を入力するか、既存の管理者ユーザーを使用するには両方を空にしてください',
+      passwordsDoNotMatch: 'パスワードが一致しません',
+      passwordMinLength: 'パスワードは6文字以上必要です',
+    },
+  },
+
+  // ユーザーページ
+  users: {
+    title: 'ユーザー管理',
+    description: 'Bambuddyインスタンスのユーザーとアクセスを管理します',
+    noPermission: 'このページにアクセスする権限がありません。',
+    backToSettings: '設定に戻る',
+    createUser: 'ユーザーを作成',
+    deleteUser: 'ユーザーを削除',
+    deleteConfirmMessage: 'このユーザーを削除してもよろしいですか?この操作は元に戻せません。',
+    password: 'パスワード',
+    placeholderUsername: 'ユーザー名を入力',
+    placeholderPassword: 'パスワードを入力',
+    roleUser: 'ユーザー',
+    roleAdmin: '管理者',
+    statusActive: 'アクティブ',
+    statusInactive: '非アクティブ',
+    save: '保存',
+    cancel: 'キャンセル',
+    edit: '編集',
+    delete: '削除',
+    creating: '作成中...',
+    admin: '管理者',
+    editUser: 'ユーザーを編集',
+    groups: 'グループ',
+    noGroups: 'グループなし',
+    noGroupsAvailable: '利用可能なグループがありません',
+    confirmPassword: 'パスワードの確認',
+    placeholderNewPassword: '新しいパスワード',
+    placeholderConfirmPassword: 'パスワードの確認',
+    placeholderConfirmNewPassword: '新しいパスワードの確認',
+    leaveBlankToKeepCurrent: '現在のパスワードを維持する場合は空のままにしてください',
+    saveChanges: '変更を保存',
+    saving: '保存中...',
+    system: 'システム',
+    table: {
+      username: 'ユーザー名',
+      role: 'ロール',
+      status: 'ステータス',
+      actions: 'アクション',
+      groups: 'グループ',
+    },
+    toast: {
+      userCreated: 'ユーザーが正常に作成されました',
+      userUpdated: 'ユーザーが正常に更新されました',
+      userDeleted: 'ユーザーが正常に削除されました',
+      fillRequiredFields: '必須項目をすべて入力してください',
+      passwordMinLength: 'パスワードは6文字以上である必要があります',
+      passwordsDoNotMatch: 'パスワードが一致しません',
+    },
+  },
+
+  // アップロードモーダル
+  upload: {
+    title: '3MFファイルのアップロード',
+    dragDrop: '.3mfファイルをここにドラッグ&ドロップ',
+    or: 'または',
+    browseFiles: 'ファイルを参照',
+    associatePrinter: 'プリンターに関連付け(任意)',
+    noPrinter: 'プリンターなし',
+    uploaded: 'アップロード済み',
+    failedCount: '失敗',
+    filesUploaded: '{{count}}ファイルをアップロードしました',
+    printerModelExtracted: 'プリンターモデルは3MFメタデータから自動抽出されます',
+    filesFailed: '{{count}}ファイルのアップロードに失敗しました',
+    partialResult: '{{uploaded}}件アップロード、{{failed}}件失敗',
+    failed: 'アップロードに失敗しました',
+    uploading: 'アップロード中...',
+    upload: 'アップロード',
+  },
+
+  // G-codeビューアー
+  gcode: {
+    loading: 'G-codeを読み込み中...',
+    notAvailable: 'G-codeが利用できません',
+    notSlicedMessage: 'このファイルはまだスライスされていません。G-codeプレビューはBambu StudioまたはOrca Slicerでスライス後に利用可能です。',
+    loadFailed: 'G-codeの読み込みに失敗しました',
+  },
+
+  // ダッシュボード
+  dashboard: {
+    dragToReorder: 'ドラッグして並べ替え',
+    sizeClickCycle: 'サイズ: {{size}} - クリックで変更',
+    hideWidget: 'ウィジェットを非表示',
+    resetLayout: 'レイアウトをリセット',
+    hiddenCount: '{{count}}件非表示',
+    hiddenWidgets: '非表示のウィジェット(クリックで表示):',
+    allHidden: 'すべてのウィジェットが非表示です。',
+  },
+
+  // プリンターキューウィジェット
+  printerQueue: {
+    nextInQueue: '次のキュー',
+    asap: '即時',
+    now: '今すぐ',
+    inLessThanMin: '1分以内',
+    inMinutes: '{{count}}分後',
+    inHours: '{{count}}時間後',
+  },
+
+  // スマートプラグ
+  smartPlug: {
+    offline: 'オフライン',
+    admin: '管理',
+    openAdminPage: 'プラグの管理ページを開く',
+    linkedTo: '接続先:',
+    alerts: 'アラート',
+    automationSettings: '自動化設定',
+    showInSwitchbar: 'スイッチバーに表示',
+    switchbarDesc: 'サイドバーからクイックアクセス',
+    enabledDesc: 'このプラグの自動化を有効にする',
+    autoOffDesc: '印刷完了後に自動オフ(1回のみ)',
+    delayMode: 'オフ遅延モード',
+    temp: '温度',
+    delayMinutes: '遅延(分)',
+    tempThreshold: '温度しきい値(℃)',
+    tempThresholdDesc: 'ノズルがこの温度以下に冷えたらオフにします',
+    deleteTitle: 'スマートプラグを削除',
+    deleteConfirm: '"{{name}}"を削除してもよろしいですか?この操作は元に戻せません。',
+    turnOnTitle: 'スマートプラグをオン',
+    turnOnConfirm: '"{{name}}"をオンにしてもよろしいですか?',
+    turnOn: 'オン',
+    turnOffTitle: 'スマートプラグをオフ',
+    turnOffConfirm: '"{{name}}"をオフにしてもよろしいですか?接続されたデバイスの電源が切断されます。',
+    turnOff: 'オフ',
+    controlFailed: '"{{name}}"の{{action}}に失敗しました',
+    monitorOnly: 'モニタリングのみ',
+    waiting: 'データを待機中...',
+    monitor: 'モニター',
+    power: '電力',
+    kwhToday: '本日の消費電力',
+    settings: '設定',
+    smartSwitches: 'スマートスイッチ',
+    noSwitchesInSwitchbar: 'スイッチバーに有効なスイッチがありません',
+    enableInSettings: '設定 → スマートプラグで有効にしてください',
+  },
+
+  // スマートプラグの追加/編集モーダル
+  addSmartPlug: {
+    addTitle: 'スマートプラグを追加',
+    editTitle: 'スマートプラグを編集',
+    // プラグタイプ選択
+    tasmota: 'Tasmota',
+    homeAssistant: 'Home Assistant',
+    // デバイス検出
+    stopScanning: 'スキャンを停止',
+    discoverDevices: 'Tasmotaデバイスを検出',
+    scanningNetwork: 'ネットワークをスキャン中...',
+    foundDevices: '{{count}}台のデバイスが見つかりました - クリックして選択:',
+    noDevicesFound: 'ネットワーク上にTasmotaデバイスが見つかりません',
+    failedToStartScan: 'スキャンの開始に失敗しました',
+    // Home Assistant
+    haNotConfigured: 'Home Assistantが設定されていません。',
+    haNotConfiguredPath: '設定 \u2192 ネットワーク \u2192 Home Assistant で設定してください',
+    selectEntity: 'エンティティを選択',
+    chooseEntity: 'エンティティを選択...',
+    loadingEntities: 'エンティティを読み込み中...',
+    failedToLoadEntities: 'エンティティの読み込みに失敗しました: {{error}}',
+    searchEntities: 'エンティティを検索...',
+    noEntitiesMatching: '「{{search}}」に一致するエンティティが見つかりません',
+    noEntitiesAvailable: '利用可能なエンティティがありません',
+    searchingEntities: 'すべてのエンティティを検索中({{count}}件見つかりました)',
+    showingEntities: 'switch、light、input_booleanを表示中({{count}}件利用可能)',
+    // エネルギーモニタリング
+    energyMonitoring: 'エネルギーモニタリング(任意)',
+    energyMonitoringDescription: '電力/エネルギーデータを提供するセンサーを検索して選択します。',
+    powerSensor: '電力センサー(W)',
+    searchPowerSensors: '電力センサーを検索...',
+    energyToday: '今日のエネルギー(kWh)',
+    totalEnergy: '総エネルギー(kWh)',
+    searchEnergySensors: 'エネルギーセンサーを検索...',
+    noMatchingSensors: '一致するセンサーがありません',
+    // IPアドレスとテスト
+    ipAddress: 'IPアドレス',
+    ipAddressPlaceholder: '192.168.1.100',
+    test: 'テスト',
+    connected: '接続されました!',
+    device: 'デバイス',
+    state: '状態',
+    connectionFailed: '接続に失敗しました',
+    // 名前
+    name: '名前',
+    namePlaceholder: 'リビングのプラグ',
+    // 認証
+    username: 'ユーザー名',
+    usernamePlaceholder: 'admin',
+    password: 'パスワード',
+    passwordPlaceholder: '********',
+    authHint: 'Tasmotaデバイスが認証を必要としない場合は空のままにしてください',
+    // プリンターとの連携
+    linkToPrinter: 'プリンターに連携',
+    noPrinterOption: 'プリンターなし(手動制御のみ)',
+    linkToPrinterHint: '連携すると印刷の開始/完了時に自動オン/オフが有効になります',
+    // 電力アラート
+    powerAlerts: '電力アラート',
+    alertAbove: '超過時にアラート(W)',
+    alertAbovePlaceholder: '例: 200',
+    alertBelow: '下回った時にアラート(W)',
+    alertBelowPlaceholder: '例: 10',
+    powerAlertHint: '消費電力がこれらのしきい値を超えた/下回った場合に通知します。無効にするには空のままにしてください。',
+    // スケジュール
+    dailySchedule: 'デイリースケジュール',
+    turnOnAt: 'オンにする時刻',
+    turnOffAt: 'オフにする時刻',
+    scheduleHint: '毎日これらの時刻にプラグを自動的にオン/オフします。スキップするには空のままにしてください。',
+    // スイッチバー
+    showInSwitchbar: 'スイッチバーに表示',
+    showInSwitchbarHint: 'サイドバーからクイックアクセス',
+    // バリデーション
+    nameRequired: '名前は必須です',
+    ipRequired: 'Tasmotaプラグの場合、IPアドレスは必須です',
+    entityRequired: 'Home Assistantプラグの場合、エンティティは必須です',
+    // MQTT
+    mqtt: 'MQTT',
+    mqttTopicRequired: 'MQTTトピックは、電力、エネルギー、またはステートのいずれかを設定する必要があります',
+    mqttBrokerNotConfigured: 'MQTTブローカーが設定されていません。ブローカーアドレスを設定してください:',
+    mqttBrokerNotConfiguredPath: '設定 → ネットワーク → MQTT発行',
+    mqttBrokerNotConfiguredHint: '(パブリッシュを有効にする必要はありません。ブローカーの詳細を入力するだけです)。',
+    mqttMonitorOnly: 'モニタリングのみ',
+    mqttMonitorOnlyDescription: 'MQTTプラグはMQTTサブスクリプション経由で電力/エネルギーデータを受信します。オン/オフ制御はできません - MQTTブローカーまたはホームオートメーションシステムを使用してください。',
+    mqttPowerMonitoring: '電力モニタリング',
+    mqttEnergyMonitoring: 'エネルギーモニタリング',
+    mqttStateMonitoring: 'ステートモニタリング',
+    mqttTopic: 'トピック',
+    mqttJsonPath: 'JSONパス',
+    mqttMultiplier: '乗数',
+    mqttOnValue: 'ONの値',
+    mqttPowerJsonPathHint: 'JSONパスはJSONペイロードから値を抽出します(例:「power_l1」)。トピックが生の数値を発行する場合は空のままにしてください。',
+    mqttPowerMultiplierHint: '乗数 0.001 で mW→W、1000 で kW→W に変換します。',
+    mqttEnergyJsonPathHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。',
+    mqttEnergyMultiplierHint: '乗数 0.001 で Wh→kWh、1000 で MWh→kWh に変換します。',
+    mqttStateJsonPathHint: 'JSONパスはJSONペイロードから値を抽出します。生の値の場合は空のままにしてください。',
+    mqttStateOnValueHint: 'ONの値:「ON」を意味する正確な文字列。自動検出(ON、true、1)の場合は空のままにしてください。',
+  },
+
+  // 通知プロバイダーの追加/編集モーダル
+  addNotification: {
+    addTitle: '通知プロバイダーを追加',
+    editTitle: '通知プロバイダーを編集',
+    nameRequired: '名前は必須です',
+    fieldRequired: '{{field}}は必須です',
+    namePlaceholder: 'マイ通知',
+    providerType: 'プロバイダータイプ',
+    configuration: '設定',
+    testConfiguration: '設定をテスト',
+    printerFilter: 'プリンターフィルター',
+    allPrinters: 'すべてのプリンター',
+    printerFilterDesc: 'このプリンターのイベントのみ通知を送信',
+    quietHours: 'サイレント時間(おやすみモード)',
+    start: '開始',
+    end: '終了',
+    dailyDigest: 'デイリーダイジェスト',
+    dailyDigestDesc: '通知をまとめて1日1回のサマリーとして送信',
+    sendDigestAt: 'ダイジェスト送信時刻',
+    digestDescription: 'イベントを収集し、この時刻にまとめて送信します',
+    notificationEvents: '通知イベント',
+    printEvents: '印刷イベント',
+    printerStatus: 'プリンターステータス',
+    events: {
+      start: '開始',
+      complete: '完了',
+      failed: '失敗',
+      stopped: '停止',
+      progress: '進捗',
+      offline: 'オフライン',
+      error: 'エラー',
+      lowFilament: 'フィラメント残量少',
+      maintenance: 'メンテナンス',
+    },
+    config: {
+      callmebot: {
+        phoneNumber: '電話番号',
+        apiKey: 'APIキー',
+        apiKeyPlaceholder: 'CallMeBot APIキー',
+      },
+      ntfy: {
+        serverUrl: 'サーバーURL',
+        topic: 'トピック',
+        authToken: '認証トークン',
+        authTokenPlaceholder: '認証(任意)',
+      },
+      pushover: {
+        userKey: 'ユーザーキー',
+        userKeyPlaceholder: 'Pushoverユーザーキー',
+        appToken: 'アプリトークン',
+        appTokenPlaceholder: 'Pushoverアプリトークン',
+        priority: '優先度',
+        priorityPlaceholder: '0(通常)',
+      },
+      telegram: {
+        botToken: 'Botトークン',
+        botTokenPlaceholder: '@BotFatherから取得したトークン',
+        chatId: 'チャットID',
+        chatIdPlaceholder: 'チャットまたはグループID',
+      },
+      email: {
+        smtpServer: 'SMTPサーバー',
+        smtpPort: 'SMTPポート',
+        security: 'セキュリティ',
+        starttls: 'STARTTLS(ポート587)',
+        ssl: 'SSL/TLS(ポート465)',
+        securityNone: 'なし(ポート25)',
+        authentication: '認証',
+        username: 'ユーザー名',
+        password: 'パスワード',
+        passwordPlaceholder: 'アプリパスワード',
+        fromEmail: '送信元メール',
+        toEmail: '送信先メール',
+      },
+      discord: {
+        webhookUrl: 'Webhook URL',
+      },
+      webhook: {
+        webhookUrl: 'Webhook URL',
+        payloadFormat: 'ペイロード形式',
+        genericJson: '汎用JSON',
+        slackMattermost: 'Slack / Mattermost',
+        authorization: '認証',
+        authorizationPlaceholder: 'Bearerトークン(任意)',
+        titleFieldName: 'タイトルフィールド名',
+        messageFieldName: 'メッセージフィールド名',
+      },
+    },
+  },
+
+  // アップロード待ち
+  pendingUploads: {
+    title: '保留中のアップロード({{count}}件)',
+    justNow: 'たった今',
+    minutesAgo: '{{count}}分前',
+    hoursAgo: '{{count}}時間前',
+    daysAgo: '{{count}}日前',
+    fromSource: '{{ip}}から',
+    archive: 'アーカイブ',
+    archiveAll: 'すべてアーカイブ',
+    discardAll: 'すべて破棄',
+    discard: '破棄',
+    discardTitle: 'アップロードを破棄',
+    discardConfirm: '"{{filename}}"を破棄してもよろしいですか?この操作は元に戻せません。',
+    tags: 'タグ',
+    tagsPlaceholder: '例: 機能部品、プロトタイプ、ギフト',
+    notes: 'メモ',
+    notesPlaceholder: 'この印刷についてメモを追加...',
+    project: 'プロジェクト',
+    noProject: 'プロジェクトなし',
+    description: 'これらのファイルはバーチャルプリンター経由でアップロードされました。確認してアーカイブし、コレクションに追加してください。',
+    archiveAllTitle: 'すべてのアップロードをアーカイブ',
+    archiveAllConfirm: '保留中のアップロード{{count}}件をすべてアーカイブしてもよろしいですか?',
+    discardAllTitle: 'すべてのアップロードを破棄',
+    discardAllConfirm: '保留中のアップロード{{count}}件をすべて破棄してもよろしいですか?この操作は元に戻せません。',
+    archived: 'アーカイブ済み: {{name}}',
+    archiveFailed: 'アーカイブに失敗しました',
+    discarded: 'アップロードを破棄しました',
+    discardFailed: '破棄に失敗しました',
+    archivedCount: '{{count}}ファイルをアーカイブしました',
+    discardedCount: '{{count}}ファイルを破棄しました',
+    archiveAllFailed: 'すべてのアーカイブに失敗しました',
+    discardAllFailed: 'すべての破棄に失敗しました',
+  },
+
+  // キーボードショートカット
+  shortcuts: {
+    title: 'キーボードショートカット',
+    navigation: 'ナビゲーション',
+    archives: 'アーカイブ',
+    profiles: 'K-プロファイル',
+    general: '一般',
+    goToPrinters: 'プリンターへ',
+    goToArchives: 'アーカイブへ',
+    goToQueue: 'キューへ',
+    goToStatistics: '統計へ',
+    goToProfiles: 'クラウドプロファイルへ',
+    goToSettings: '設定へ',
+    openItem: '{{item}}を開く',
+    goToItem: '{{item}}へ移動',
+    focusSearch: '検索にフォーカス',
+    openUpload: 'アップロードダイアログを開く',
+    clearSelection: '選択解除 / 入力をクリア',
+    contextMenu: 'カードのコンテキストメニュー',
+    refreshProfiles: 'プロファイルを更新',
+    newProfile: '新しいプロファイル',
+    exitSelection: '選択モードを終了',
+    showHelp: 'このヘルプを表示',
+    pressKey: '押す',
+    orClickToClose: 'または外側をクリックで閉じる',
+  },
+
+  // ログビューアー
+  logViewer: {
+    title: 'アプリケーションログ',
+    liveStreaming: 'ライブストリーミング - {{count}}エントリ',
+    description: 'アプリケーションログの表示とフィルタ',
+    live: 'ライブ',
+    stop: '停止',
+    start: '開始',
+    clear: 'クリア',
+    autoScroll: '自動スクロール',
+    searchPlaceholder: 'メッセージまたはロガー名で検索...',
+    noEntries: 'ログエントリが見つかりません',
+    emptyLog: 'ログファイルが空またはクリアされています',
+    autoRefreshing: '2秒ごとに自動更新中',
+    clickStart: '開始をクリックしてライブログストリーミングを有効にする',
+  },
+
+  // アーカイブ編集モーダル
+  editArchive: {
+    title: 'アーカイブを編集',
+    name: '名前',
+    printNamePlaceholder: '印刷名',
+    printer: 'プリンター',
+    noPrinter: 'プリンターなし',
+    project: 'プロジェクト',
+    noProject: 'プロジェクトなし',
+    itemsPrinted: '印刷数',
+    itemsPrintedDescription: 'この印刷ジョブで生産されたアイテム数',
+    notes: 'メモ',
+    notesPlaceholder: 'この印刷についてメモを追加...',
+    tags: 'タグ',
+    addTags: 'タグを追加...',
+    addMoreTags: 'タグをさらに追加...',
+    existingTagsHint: '既存のタグ(クリックで追加)',
+    externalLink: '外部リンク',
+    externalLinkPlaceholder: 'https://...',
+    externalLinkDescription: 'MakerWorld、Printables、Thingiverseなどへのリンク',
+    status: 'ステータス',
+    statusCompleted: '完了',
+    statusFailed: '失敗',
+    statusCancelled: 'キャンセル',
+    statusPrinting: '印刷中',
+    failureReason: '失敗理由',
+    selectReason: '理由を選択...',
+    failureReasons: {
+      adhesionFailure: '定着不良',
+      spaghettiDetached: 'スパゲッティ / 剥離',
+      layerShift: 'レイヤーシフト',
+      cloggedNozzle: 'ノズル詰まり',
+      filamentRunout: 'フィラメント切れ',
+      warping: '反り',
+      stringing: '糸引き',
+      underExtrusion: '押出不足',
+      powerFailure: '電源障害',
+      userCancelled: 'ユーザーによるキャンセル',
+      other: 'その他',
+    },
+    photos: '印刷結果の写真',
+    printResult: '印刷結果',
+    photosDescription: '+をクリックして印刷結果の写真を追加',
+    saving: '保存中...',
+  },
+
+  // MQTTデバッグ
+  mqttDebug: {
+    title: 'MQTTデバッグログ',
+    empty: '<空>',
+    startLogging: 'ログ記録を開始',
+    searchPlaceholder: 'トピックまたはペイロードで検索...',
+    incoming: '受信',
+    outgoing: '送信',
+    noMessages: 'まだメッセージが記録されていません',
+    clickStart: '「ログ記録を開始」をクリックしてMQTTメッセージの記録を開始',
+    noMatch: 'フィルタに一致するメッセージがありません',
+    adjustFilter: '検索条件またはフィルタ条件を調整してみてください',
+    loggingActive: 'ログ記録中 - メッセージは自動更新されます',
+    loggingStopped: 'ログ記録停止',
+  },
+
+  // アーカイブアクション
+  archiveActions: {
+    print: '印刷',
+    schedule: 'スケジュール',
+    openInSlicer: 'Bambu Studioで開く',
+    slice: 'スライス',
+    viewMakerWorld: 'MakerWorldで表示',
+    preview3d: '3Dプレビュー',
+    viewTimelapse: 'タイムラプスを表示',
+    scanTimelapse: 'タイムラプスを検索',
+    downloadSource3mf: 'ソース3MFをダウンロード',
+    uploadSource3mf: 'ソース3MFをアップロード',
+    replaceSource3mf: 'ソース3MFを置換',
+    removeSource3mf: 'ソース3MFを削除',
+    replaceF3d: 'F3Dを置換',
+    uploadF3d: 'F3Dをアップロード',
+    downloadF3d: 'F3Dをダウンロード',
+    removeF3d: 'F3Dを削除',
+    copyLink: 'ダウンロードリンクをコピー',
+    qrCode: 'QRコード',
+    viewPhotos: '写真を表示',
+    projectPage: 'プロジェクトページ',
+    goToProject: 'プロジェクトへ: {{name}}',
+    addToProject: 'プロジェクトに追加',
+    removeFromProject: 'プロジェクトから削除',
+    noProjects: '利用可能なプロジェクトがありません',
+    deselect: '選択解除',
+    select: '選択',
+    remove: '削除',
+    source3mfAttached: 'ソース3MFを添付しました: {{filename}}',
+    source3mfUploadFailed: 'ソース3MFのアップロードに失敗しました',
+    source3mfRemoved: 'ソース3MFを削除しました',
+    source3mfRemoveFailed: 'ソース3MFの削除に失敗しました',
+    f3dAttached: 'F3Dを添付しました: {{filename}}',
+    f3dUploadFailed: 'F3Dのアップロードに失敗しました',
+    f3dRemoved: 'F3Dを削除しました',
+    f3dRemoveFailed: 'F3Dの削除に失敗しました',
+    timelapseAttached: 'タイムラプスを添付しました: {{filename}}',
+    timelapseExists: 'タイムラプスは既に添付されています',
+    noTimelapseFound: '一致するタイムラプスが見つかりません',
+    timelapseScanFailed: 'タイムラプスの検索に失敗しました',
+    timelapseAttachFailed: 'タイムラプスの添付に失敗しました',
+    deleted: 'アーカイブを削除しました',
+    deleteFailed: 'アーカイブの削除に失敗しました',
+    addedToFavorites: 'お気に入りに追加しました',
+    removedFromFavorites: 'お気に入りから削除しました',
+    projectUpdated: 'プロジェクトを更新しました',
+    projectUpdateFailed: 'プロジェクトの更新に失敗しました',
+    linkCopied: 'リンクをクリップボードにコピーしました',
+    linkCopyFailed: 'リンクのコピーに失敗しました',
+    photoDeleted: '写真を削除しました',
+    photoDeleteFailed: '写真の削除に失敗しました',
+    deleteTitle: 'アーカイブを削除',
+    deleteConfirm: '"{{name}}"を削除してもよろしいですか?この操作は元に戻せません。',
+    removeSource3mfTitle: 'ソース3MFを削除',
+    removeSource3mfConfirm: '"{{name}}"からソース3MFファイルを削除してもよろしいですか?元のスライサープロジェクトファイルが削除されます。',
+    removeF3dTitle: 'F3Dを削除',
+    removeF3dConfirm: '"{{name}}"からFusion 360デザインファイルを削除してもよろしいですか?',
+    selectTimelapse: 'タイムラプスを選択',
+    selectTimelapseDesc: '自動一致が見つかりませんでした。この印刷のタイムラプスを選択してください:',
+  },
+
+  // カメラページ
+  camera: {
+    camera: 'カメラ',
+    invalidPrinterId: '無効なプリンターID',
+    printerFallback: 'プリンター #{{id}}',
+    // モード切替
+    live: 'ライブ',
+    snapshot: 'スナップショット',
+    // コントロール
+    restartStream: 'ストリームを再開',
+    refreshSnapshot: 'スナップショットを更新',
+    fullscreen: 'フルスクリーン',
+    exitFullscreen: 'フルスクリーンを終了',
+    // 読み込み状態
+    connectingToCamera: 'カメラに接続中...',
+    capturingSnapshot: 'スナップショットを撮影中...',
+    // 再接続
+    connectionLost: '接続が切断されました',
+    reconnectingIn: '{{seconds}}秒後に再接続します(試行{{attempt}}/{{maxAttempts}})',
+    reconnectNow: '今すぐ再接続',
+    // エラー状態
+    cameraUnavailable: 'カメラが利用できません',
+    makeSurePrinterConnected: 'プリンターが接続されていること、およびプリンター設定でカメラが有効になっていることを確認してください。',
+    retry: '再試行',
+    // 画像
+    cameraStream: 'カメラストリーム',
+    // ズームコントロール
+    zoomIn: 'ズームイン',
+    zoomOut: 'ズームアウト',
+    resetZoom: 'ズームをリセット',
+    skipBack: '5秒戻る',
+    skipForward: '5秒進む',
+    refreshStream: 'ストリームを更新',
+    dragToResize: 'ドラッグしてリサイズ',
+  },
+
+  // アーカイブカードラベル
+  archiveCard: {
+    slicedReady: 'スライス済みファイル - 印刷可能',
+    sourceOnly: 'ソースファイルのみ - AMSマッピングなし',
+    gcode: 'GCODE',
+    source: 'ソース',
+    layers: 'レイヤー',
+    objects: '{{count}}オブジェクト',
+    objects_other: '{{count}}オブジェクト',
+    cancelled: 'キャンセル',
+    failed: '失敗',
+    duplicate: '重複',
+  },
+
+  // 通知プロバイダーカード
+  providerCard: {
+    last: '最終: ',
+    lastSuccessAt: '最終: {{date}}',
+    printer: 'プリンター: ',
+    allPrinters: 'すべてのプリンター',
+    tagStart: '開始',
+    tagComplete: '完了',
+    tagFailed: '失敗',
+    tagStopped: '中止',
+    tagProgress: '進捗',
+    tagOffline: 'オフライン',
+    tagLowFilament: 'フィラメント残量低下',
+    tagMaintenance: 'メンテナンス',
+    tagAmsHumidity: 'AMS湿度',
+    tagAmsTemp: 'AMS温度',
+    tagAmsHtHumidity: 'AMS-HT湿度',
+    tagAmsHtTemp: 'AMS-HT温度',
+    quiet: 'おやすみ',
+    digest: 'ダイジェスト ',
+    digestAt: 'ダイジェスト {{time}}',
+    sendTest: 'テスト通知を送信',
+    eventSettings: 'イベント設定',
+    sendNotifications: 'このプロバイダーから通知を送信',
+    printEvents: '印刷イベント',
+    printerStatus: 'プリンターステータス',
+    amsAlarms: 'AMSアラーム',
+    amsHumidityHigh: 'AMS湿度高',
+    amsHumidityHighDesc: '通常AMSの湿度がしきい値を超過',
+    amsTempHigh: 'AMS温度高',
+    amsTempHighDesc: '通常AMSの温度がしきい値を超過',
+    amsHtAlarms: 'AMS-HTアラーム',
+    amsHtHumidityHigh: 'AMS-HT湿度高',
+    amsHtHumidityHighDesc: 'AMS-HTの湿度がしきい値を超過',
+    amsHtTempHigh: 'AMS-HT温度高',
+    amsHtTempHighDesc: 'AMS-HTの温度がしきい値を超過',
+    noNotificationsDuring: 'この時間帯は通知を送信しません',
+    editQuietHours: 'プロバイダーを編集しておやすみ時間を変更',
+    dailyDigest: 'デイリーダイジェスト',
+    dailyDigestDesc: '通知を1日1回のサマリーにまとめる',
+    sendAt: '送信時刻',
+    editDigestTime: 'プロバイダーを編集してダイジェスト時刻を変更',
+    deleteTitle: '通知プロバイダーを削除',
+    deleteConfirm: '「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。',
+  },
+
+  // Projects page
+  projects: {
+    title: 'プロジェクト',
+    subtitle: '印刷プロジェクトを管理',
+    editProject: 'プロジェクトを編集',
+    newProject: '新規プロジェクト',
+    namePlaceholder: 'プロジェクト名',
+    descriptionPlaceholder: 'プロジェクトの説明(任意)',
+    color: '色',
+    targetPlates: '目標プレート数',
+    targetPlatesPlaceholder: '例: 10',
+    targetPlatesHint: '必要な印刷ジョブ/プレート数',
+    targetParts: '目標パーツ数',
+    targetPartsPlaceholder: '例: 50',
+    targetPartsHint: '必要な個別パーツの総数',
+    tags: 'タグ',
+    tagsPlaceholder: 'カンマ区切りのタグ',
+    dueDate: '期限',
+    priorityLabel: '優先度',
+    priority: {
+      low: '低',
+      normal: '通常',
+      high: '高',
+      urgent: '緊急',
+    },
+    status: {
+      active: '進行中',
+      completed: '完了',
+      archived: 'アーカイブ済み',
+    },
+    create: '作成',
+    parts: 'パーツ',
+    plates: 'プレート',
+    failed: '失敗',
+    completed: '完了',
+    inQueue: 'キュー内',
+    noPrintsYet: '印刷履歴なし',
+    more: 'もっと見る',
+    printJobsPlates: '印刷ジョブ / プレート',
+    partsPrinted: '印刷済みパーツ',
+    failedParts: '失敗パーツ',
+    projectCreated: 'プロジェクトを作成しました',
+    projectUpdated: 'プロジェクトを更新しました',
+    projectDeleted: 'プロジェクトを削除しました',
+    all: 'すべて',
+    loading: 'プロジェクトを読み込み中...',
+    noProjectsYet: 'プロジェクトがまだありません',
+    noStatusProjects: '{{status}}のプロジェクトはありません',
+    noProjectsHint: 'プロジェクトを作成して印刷を整理・追跡しましょう',
+    noStatusProjectsHint: 'ステータスが「{{status}}」のプロジェクトはありません',
+    createFirstProject: '最初のプロジェクトを作成',
+    deleteProject: 'プロジェクトを削除',
+    deleteProjectConfirm: 'このプロジェクトを削除しますか?この操作は取り消せません。',
+    exportAllProjects: 'すべてのプロジェクトをエクスポート',
+    importProject: 'プロジェクトをインポート',
+    importFailed: 'インポートに失敗しました',
+    projectImported: 'プロジェクトがインポートされました',
+    projectsExported: 'プロジェクトがエクスポートされました',
+    noCreatePermission: 'プロジェクトを作成する権限がありません',
+    noDeletePermission: 'プロジェクトを削除する権限がありません',
+    noEditPermission: 'プロジェクトを編集する権限がありません',
+    noExportPermission: 'プロジェクトをエクスポートする権限がありません',
+    noImportPermission: 'プロジェクトをインポートする権限がありません',
+  },
+
+  // Project detail page
+  projectDetail: {
+    statusActive: '進行中',
+    statusCompleted: '完了',
+    statusArchived: 'アーカイブ済み',
+    noPrintsYet: '印刷履歴なし',
+    print: '印刷',
+    unknown: '不明',
+    priorityLow: '低',
+    priorityNormal: '通常',
+    priorityHigh: '高',
+    priorityUrgent: '緊急',
+    overdue: '期限超過',
+    dueToday: '本日期限',
+    daysLeft: '残り{{count}}日',
+    projectUpdated: 'プロジェクトを更新しました',
+    partAdded: 'パーツを追加しました',
+    partRemoved: 'パーツを削除しました',
+    deletePart: 'パーツを削除',
+    deletePartConfirm: '「{{name}}」を削除しますか?',
+    templateCreated: 'プロジェクトからテンプレートを作成しました',
+    error: 'エラー',
+    projectNotFound: 'プロジェクトが見つかりません',
+    backToProjects: 'プロジェクト一覧に戻る',
+    projects: 'プロジェクト',
+    edit: '編集',
+    platesProgress: 'プレート進捗',
+    printJobs: '印刷ジョブ',
+    percentComplete: '% 完了',
+    remaining: '残り',
+    partsProgress: 'パーツ進捗',
+    parts: 'パーツ',
+    printJobsCard: '印刷ジョブ',
+    total: '合計',
+    failed: '失敗',
+    partsPrinted: '印刷済みパーツ',
+    printTime: '印刷時間',
+    filamentUsed: 'フィラメント使用量',
+    costTracking: 'コスト追跡',
+    filamentCost: 'フィラメント',
+    energy: 'エネルギー',
+    budget: '予算',
+    budgetRemaining: '残り予算',
+    subProjects: 'サブプロジェクト ({{count}})',
+    partOf: '所属先',
+    priority: '優先度',
+    notes: 'メモ',
+    cancel: 'キャンセル',
+    save: '保存',
+    notesPlaceholder: 'このプロジェクトについてメモを追加...',
+    noNotesYet: 'メモがありません。編集をクリックして追加してください。',
+    files: 'ファイル',
+    linkFoldersFromFileManager: 'ファイルマネージャーからフォルダーをリンク',
+    toThisProjectForQuickAccess: 'してクイックアクセス。',
+    fileCount: '{{count}}ファイル',
+    noFoldersLinked: 'リンクされたフォルダーはありません',
+    billOfMaterials: '部品表(BOM)',
+    bomAcquired: '{{completed}}/{{total}} 取得済み',
+    showAll: 'すべて表示',
+    hideDone: '完了を非表示',
+    addPart: 'パーツを追加',
+    partNamePlaceholder: 'パーツ名',
+    qty: '数量',
+    pricePlaceholder: '価格 ({{currency}})',
+    sourcingUrlPlaceholder: 'URL(任意)',
+    remarksPlaceholder: '備考',
+    delete: '削除',
+    totalCost: '合計コスト',
+    noBomParts: 'BOMにパーツがありません',
+    activityTimeline: 'アクティビティタイムライン',
+    noActivityYet: 'アクティビティがありません',
+    timelineEvents: {
+      project_created: 'プロジェクト作成',
+      print_completed: '印刷完了',
+      print_failed: '印刷失敗',
+      print_started: '印刷開始',
+      queued: 'キューに追加',
+    },
+    saveAsTemplate: 'テンプレートとして保存',
+    queue: 'キュー',
+    viewAll: 'すべて表示',
+    printing: '印刷中',
+    queued: 'キュー内',
+    prints: '{{count}}件の印刷',
+    exportProject: 'プロジェクトをエクスポート',
+    exportFailed: 'エクスポートに失敗しました',
+    projectExported: 'プロジェクトがエクスポートされました',
+    noEditPermission: 'このプロジェクトを編集する権限がありません',
+    noAddPartsPermission: 'パーツを追加する権限がありません',
+    noDeletePartsPermission: 'パーツを削除する権限がありません',
+    noEditPartsPermission: 'パーツを編集する権限がありません',
+    noUpdatePartsPermission: 'パーツを更新する権限がありません',
+    noEditNotesPermission: 'ノートを編集する権限がありません',
+    noCreateTemplatesPermission: 'テンプレートを作成する権限がありません',
+    noExportPermission: 'プロジェクトをエクスポートする権限がありません',
+  },
+
+  // Project page modal
+  projectPage: {
+    title: 'プロジェクトページ',
+    loadFailed: 'プロジェクトページデータの読み込みに失敗',
+    noData: 'プロジェクトページデータがありません',
+    noDataHint: 'このプロジェクトにはMakerWorldページデータがありません',
+    titleField: 'タイトル',
+    designer: 'デザイナー',
+    license: 'ライセンス',
+    description: '説明',
+    descriptionPlaceholder: 'プロジェクトの説明...',
+    printProfile: '印刷プロファイル',
+    profileTitle: 'プロファイルタイトル',
+    profileDescriptionPlaceholder: 'プロファイルの説明',
+    byUser: '{{name}} 作',
+    images: '画像 ({{count}})',
+    viewOnMakerWorld: 'MakerWorldで表示',
+  },
+
+  // Batch project modal
+  batchProject: {
+    addedToProject: '{{count}}件のアーカイブを「{{project}}」に追加しました',
+    assignFailed: 'プロジェクトへのアーカイブ割り当てに失敗しました',
+    removedFromProject: '{{count}}件のアーカイブをプロジェクトから削除しました',
+    removeFailed: 'プロジェクトからのアーカイブ削除に失敗しました',
+    assignTitle: 'プロジェクトに割り当て',
+    assignDescription: '{{count}}件の選択されたアーカイブのプロジェクトを選択:',
+    removeFromProject: 'プロジェクトから削除',
+    clearAssignment: '選択されたアーカイブのプロジェクト割り当てを解除',
+    orAssignTo: 'または割り当て先:',
+    archiveCount: '{{count}}件のアーカイブ',
+    noProjects: 'プロジェクトがありません。先にプロジェクトを作成してください。',
+  },
+
+  // リッチテキストエディター
+  richTextEditor: {
+    bold: '太字',
+    italic: '斜体',
+    underline: '下線',
+    bulletList: '箇条書き',
+    numberedList: '番号付きリスト',
+    alignLeft: '左揃え',
+    alignCenter: '中央揃え',
+    alignRight: '右揃え',
+    addLink: 'リンクを追加',
+    removeLink: 'リンクを削除',
+  },
+
+  // K-プロファイル
+  kProfiles: {
+    editProfile: 'K-プロファイルを編集',
+    addProfile: 'K-プロファイルを追加',
+    hasNote: 'メモあり',
+    copyProfile: 'プロファイルをコピー',
+    profileNamePlaceholder: 'マイPLAプロファイル',
+    notesPlaceholder: 'このプロファイルのメモを追加...',
+    searchPlaceholder: '名前またはフィラメントで検索...',
+    exportToJson: 'JSONにエクスポート',
+    importFromJson: 'JSONからインポート',
+    selectAllVisible: '表示中のプロファイルをすべて選択',
+    enterSelectionMode: '一括削除の選択モードに入る',
+    // Profile card
+    unnamed: '名前なし',
+    notePrefix: 'メモ: ',
+    // Profile modal
+    profileName: 'プロファイル名',
+    kValue: 'K値',
+    kValueRange: '一般的な範囲: PLA 0.01 - 0.06、PETG 0.02 - 0.10',
+    filament: 'フィラメント',
+    selectFilament: 'フィラメントを選択...',
+    noFilamentsFound: 'フィラメントが見つかりません。まずBambu StudioでKプロファイルを作成してください。',
+    flowType: 'フロータイプ',
+    highFlow: 'ハイフロー',
+    standard: 'スタンダード',
+    nozzleSize: 'ノズルサイズ',
+    extruder: 'エクストルーダー',
+    extruders: 'エクストルーダー',
+    left: '左',
+    right: '右',
+    notesStoredLocally: 'メモ(ローカル保存)',
+    notesHint: 'メモはBambuddyに保存され、プリンターには保存されません',
+    deleteProfileTitle: 'プロファイルを削除',
+    cannotBeUndone: 'この操作は元に戻せません',
+    confirmDeleteProfile: '"{{name}}"をプリンターから削除してもよろしいですか?',
+    profileSaved: 'Kプロファイルを保存しました',
+    profileDeleted: 'Kプロファイルを削除しました',
+    selectExtruder: 'エクストルーダーを少なくとも1つ選択してください',
+    profileSavedToExtruders: 'Kプロファイルを{{count}}個のエクストルーダーに保存しました',
+    failedToSave: 'Kプロファイルの保存に失敗しました',
+    syncingWithPrinter: 'プリンターと同期中...',
+    pleaseWait: 'お待ちください',
+    savingToExtruder: 'エクストルーダー {{current}}/{{total}} に保存中...',
+    // Main view
+    noPrintersConfigured: 'プリンターが設定されていません',
+    addPrinterHint: '設定でプリンターを追加してKプロファイルを管理',
+    noActivePrinters: 'アクティブなプリンターがありません',
+    enablePrinterHint: 'プリンター接続を有効にしてKプロファイルを表示',
+    loadingProfiles: 'Kプロファイルを読み込み中...',
+    nozzle: 'ノズル',
+    allExtruders: 'すべてのエクストルーダー',
+    leftOnly: '左のみ',
+    rightOnly: '右のみ',
+    allFlow: 'すべてのフロー',
+    hfOnly: 'HFのみ',
+    sOnly: 'Sのみ',
+    sortName: 'ソート: 名前',
+    sortKValue: 'ソート: K値',
+    sortFilament: 'ソート: フィラメント',
+    selectAll: 'すべて選択',
+    select: '選択',
+    printerOffline: 'プリンターオフライン',
+    printerOfflineHint: 'プリンターが接続されていません。電源を入れてKプロファイルを表示してください。',
+    leftExtruder: '左エクストルーダー',
+    rightExtruder: '右エクストルーダー',
+    noMatchingProfiles: '一致するプロファイルなし',
+    noProfilesMatch: '検索条件に一致するプロファイルがありません',
+    noKProfiles: 'Kプロファイルなし',
+    noProfilesForNozzle: '{{diameter}}mmノズルの圧力キャリブレーションプロファイルが見つかりません',
+    createFirstProfile: '最初のプロファイルを作成',
+    deleteProfiles: 'プロファイルを削除',
+    confirmBulkDelete: '{{count}}件の選択されたプロファイルをプリンターから削除してもよろしいですか?',
+    noProfilesToExport: 'エクスポートするプロファイルがありません',
+    exportedProfiles: '{{count}}件のプロファイルをエクスポートしました',
+    invalidFileFormat: '無効なファイル形式',
+    importedProfiles: '{{total}}件中{{imported}}件のプロファイルをインポートしました',
+    failedToParseImport: 'インポートファイルの解析に失敗しました',
+    deletedProfiles: '{{count}}件のプロファイルを削除しました',
+    failedToSaveNote: 'メモの保存に失敗しました',
+    noPermissionAdd: 'K-プロファイルを追加する権限がありません',
+    noPermissionDelete: 'K-プロファイルを削除する権限がありません',
+    noPermissionDeleteBulk: 'K-プロファイルを一括削除する権限がありません',
+    noPermissionExport: 'K-プロファイルをエクスポートする権限がありません',
+    noPermissionImport: 'K-プロファイルをインポートする権限がありません',
+    noPermissionRefresh: 'K-プロファイルを更新する権限がありません',
+  },
+
+  // APIブラウザ
+  apiBrowser: {
+    validationError: 'バリデーションエラー',
+    missingRequiredParams: '必須パラメーターが不足: {{params}}',
+    networkError: 'ネットワークエラー',
+    pathParameters: 'パスパラメーター',
+    queryParameters: 'クエリパラメーター',
+    selectOption: '-- 選択 --',
+    requestBody: 'リクエストボディ',
+    execute: '実行',
+    fillInRequired: '入力してください: {{params}}',
+    response: 'レスポンス',
+    failedToFetchSchema: 'OpenAPIスキーマの取得に失敗しました',
+    failedToLoadSchema: 'APIスキーマの読み込みに失敗しました',
+    otherCategory: 'その他',
+    expandAll: 'すべて展開',
+    collapseAll: 'すべて折りたたむ',
+    swaggerUI: 'Swagger UI',
+    endpointCount: '{{categories}}カテゴリー内{{count}}エンドポイント',
+  },
+  githubBackup: {
+    title: 'GitHubバックアップ',
+    enabled: '有効',
+    cloudLoginRequired: 'Bambu Cloudのログインが必要です。プロファイル → クラウドプロファイルからサインインしてGitHubバックアップを有効にしてください。',
+    description: 'プロファイルをプライベートGitHubリポジトリに自動同期し、バックアップとバージョン履歴を管理します。',
+    repositoryUrl: 'リポジトリURL',
+    personalAccessToken: 'パーソナルアクセストークン',
+    saved: '保存済み',
+    enterNewToken: '新しいトークンを入力して更新',
+    tokenHint: 'Contents読み取り/書き込み権限を持つFine-grainedトークン',
+    branch: 'ブランチ',
+    autoBackup: '自動バックアップ',
+    manualOnly: '手動のみ',
+    hourly: '毎時',
+    daily: '毎日',
+    weekly: '毎週',
+    includeInBackup: 'バックアップに含める',
+    kProfiles: 'Kプロファイル',
+    noPrintersConnected: 'プリンター未接続',
+    printersConnected: '{{connected}}/{{total}} 接続中',
+    kProfilesDescription: '接続されたプリンターからの圧力前進キャリブレーション',
+    cloudProfiles: 'クラウドプロファイル',
+    cloudProfilesDescription: 'Bambu Cloudのフィラメント、プリンター、プロセスプリセット',
+    appSettings: 'アプリ設定',
+    appSettingsDescription: 'Bambuddyの設定(機密データを除く)',
+    lastBackup: '最終バックアップ',
+    noBackupsYet: 'バックアップなし',
+    next: '次回',
+    startingBackup: 'バックアップ開始中...',
+    backupNow: '今すぐバックアップ',
+    test: 'テスト',
+    enableBackup: 'バックアップを有効化',
+    testConnection: '接続テスト',
+    history: '履歴',
+    date: '日付',
+    status: 'ステータス',
+    commit: 'コミット',
+    localBackup: 'ローカルバックアップ',
+    localDescription: 'Bambuddyデータをローカルファイルとしてエクスポートまたはインポートし、手動バックアップや移行に利用できます。',
+    exportData: 'データエクスポート',
+    exportDescription: 'すべての設定、プリンター、プロファイルをダウンロード',
+    importBackup: 'バックアップのインポート',
+    importDescription: '以前のエクスポートファイルから復元',
+    tokenUpdated: 'トークンを更新しました',
+    settingsSaved: '設定を保存しました',
+    failedToSave: '保存に失敗しました: {{error}}',
+    backupEnabled: 'GitHubバックアップを有効にしました',
+    backupComplete: 'バックアップ完了 - {{count}}ファイル更新',
+    backupSkipped: 'バックアップをスキップ - 変更なし',
+    backupFailed: 'バックアップに失敗しました: {{error}}',
+    logsCleared: '{{count}}件のログを削除しました',
+    failedToClearLogs: 'ログの削除に失敗しました: {{error}}',
+    enterRepoUrl: 'リポジトリURLを入力してください',
+    enterRepoUrlAndToken: 'リポジトリURLとアクセストークンを入力してください',
+    repoUrlRequired: 'リポジトリURLは必須です',
+    tokenRequired: 'アクセストークンは必須です',
+    backupDownloaded: 'バックアップのダウンロードが完了しました',
+    failedToCreateBackup: 'バックアップの作成に失敗しました',
+    backupRestored: 'バックアップの復元が完了しました',
+  },
+};

File diff suppressed because it is too large
+ 200 - 198
frontend/src/pages/ArchivesPage.tsx


+ 29 - 30
frontend/src/pages/CameraPage.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useParams } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
 import { api } from '../api/client';
 
@@ -10,6 +11,7 @@ const MAX_RECONNECT_DELAY = 30000; // 30 seconds
 const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
 
 export function CameraPage() {
+  const { t } = useTranslation();
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
 
@@ -222,21 +224,18 @@ export function CameraPage() {
     // Start stall detection after stream has loaded
     stallCheckIntervalRef.current = setInterval(async () => {
       try {
-        const response = await fetch(`/api/v1/printers/${id}/camera/status`);
-        if (response.ok) {
-          const status = await response.json();
-          // Trigger reconnect if:
-          // 1. Backend reports stall (no frames for 10+ seconds)
-          // 2. OR stream is not active anymore (process died)
-          if (status.stalled || (!status.active && !streamError)) {
-            console.log(`Stream issue detected: stalled=${status.stalled}, active=${status.active}, reconnecting...`);
-            if (stallCheckIntervalRef.current) {
-              clearInterval(stallCheckIntervalRef.current);
-              stallCheckIntervalRef.current = null;
-            }
-            setStreamLoading(false);
-            attemptReconnect();
+        const status = await api.getCameraStatus(id);
+        // Trigger reconnect if:
+        // 1. Backend reports stall (no frames for 10+ seconds)
+        // 2. OR stream is not active anymore (process died)
+        if (status.stalled || (!status.active && !streamError)) {
+          console.log(`Stream issue detected: stalled=${status.stalled}, active=${status.active}, reconnecting...`);
+          if (stallCheckIntervalRef.current) {
+            clearInterval(stallCheckIntervalRef.current);
+            stallCheckIntervalRef.current = null;
           }
+          setStreamLoading(false);
+          attemptReconnect();
         }
       } catch {
         // Ignore fetch errors - server might be temporarily unavailable
@@ -539,7 +538,7 @@ export function CameraPage() {
   if (!id) {
     return (
       <div className="min-h-screen bg-black flex items-center justify-center">
-        <p className="text-white">Invalid printer ID</p>
+        <p className="text-white">{t('camera.invalidPrinterId')}</p>
       </div>
     );
   }
@@ -564,7 +563,7 @@ export function CameraPage() {
                   : 'text-bambu-gray hover:text-white disabled:opacity-50'
               }`}
             >
-              Live
+              {t('camera.live')}
             </button>
             <button
               onClick={() => switchToMode('snapshot')}
@@ -575,21 +574,21 @@ export function CameraPage() {
                   : 'text-bambu-gray hover:text-white disabled:opacity-50'
               }`}
             >
-              Snapshot
+              {t('camera.snapshot')}
             </button>
           </div>
           <button
             onClick={refresh}
             disabled={isDisabled}
             className="p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
-            title={streamMode === 'stream' ? 'Restart stream' : 'Refresh snapshot'}
+            title={streamMode === 'stream' ? t('camera.restartStream') : t('camera.refreshSnapshot')}
           >
             <RefreshCw className={`w-4 h-4 text-bambu-gray ${isDisabled ? 'animate-spin' : ''}`} />
           </button>
           <button
             onClick={toggleFullscreen}
             className="p-1.5 hover:bg-bambu-dark-tertiary rounded"
-            title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
+            title={isFullscreen ? t('camera.exitFullscreen') : t('camera.fullscreen')}
           >
             {isFullscreen ? (
               <Minimize className="w-4 h-4 text-bambu-gray" />
@@ -618,7 +617,7 @@ export function CameraPage() {
               <div className="text-center">
                 <RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
                 <p className="text-sm text-bambu-gray">
-                  {streamMode === 'stream' ? 'Connecting to camera...' : 'Capturing snapshot...'}
+                  {streamMode === 'stream' ? t('camera.connectingToCamera') : t('camera.capturingSnapshot')}
                 </p>
               </div>
             </div>
@@ -627,15 +626,15 @@ export function CameraPage() {
             <div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
               <div className="text-center p-4">
                 <WifiOff className="w-10 h-10 text-orange-400 mx-auto mb-3" />
-                <p className="text-white mb-2">Connection lost</p>
+                <p className="text-white mb-2">{t('camera.connectionLost')}</p>
                 <p className="text-sm text-bambu-gray mb-3">
-                  Reconnecting in {reconnectCountdown}s... (attempt {reconnectAttempts + 1}/{MAX_RECONNECT_ATTEMPTS})
+                  {t('camera.reconnecting', { countdown: reconnectCountdown, attempt: reconnectAttempts + 1, max: MAX_RECONNECT_ATTEMPTS })}
                 </p>
                 <button
                   onClick={refresh}
                   className="px-4 py-2 bg-bambu-green text-white text-sm rounded hover:bg-bambu-green/80 transition-colors"
                 >
-                  Reconnect now
+                  {t('camera.reconnectNow')}
                 </button>
               </div>
             </div>
@@ -644,15 +643,15 @@ export function CameraPage() {
             <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
               <div className="text-center p-4">
                 <AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
-                <p className="text-white mb-2">Camera unavailable</p>
+                <p className="text-white mb-2">{t('camera.cameraUnavailable')}</p>
                 <p className="text-xs text-bambu-gray mb-4 max-w-md">
-                  Make sure the printer is powered on and connected.
+                  {t('camera.cameraUnavailableDesc')}
                 </p>
                 <button
                   onClick={refresh}
                   className="px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors"
                 >
-                  Retry
+                  {t('camera.retry')}
                 </button>
               </div>
             </div>
@@ -661,7 +660,7 @@ export function CameraPage() {
             ref={imgRef}
             key={imageKey}
             src={currentUrl}
-            alt="Camera stream"
+            alt={t('camera.cameraStream')}
             className="max-w-full max-h-full object-contain select-none"
             style={{
               transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
@@ -679,14 +678,14 @@ export function CameraPage() {
               onClick={handleZoomOut}
               disabled={zoomLevel <= 1}
               className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
-              title="Zoom out"
+              title={t('camera.zoomOut')}
             >
               <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"
+              title={t('camera.resetZoom')}
             >
               {Math.round(zoomLevel * 100)}%
             </button>
@@ -694,7 +693,7 @@ export function CameraPage() {
               onClick={handleZoomIn}
               disabled={zoomLevel >= 4}
               className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
-              title="Zoom in"
+              title={t('camera.zoomIn')}
             >
               <ZoomIn className="w-4 h-4 text-white" />
             </button>

+ 3 - 1
frontend/src/pages/ExternalLinkPage.tsx

@@ -1,10 +1,12 @@
 import { useParams } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { Loader2, AlertTriangle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { useTheme } from '../contexts/ThemeContext';
 
 export function ExternalLinkPage() {
+  const { t } = useTranslation();
   const { id } = useParams<{ id: string }>();
   const { mode } = useTheme();
 
@@ -26,7 +28,7 @@ export function ExternalLinkPage() {
     return (
       <div className="flex flex-col items-center justify-center h-full gap-4 text-bambu-gray">
         <AlertTriangle className="w-12 h-12" />
-        <p>Link not found</p>
+        <p>{t('common.linkNotFound')}</p>
       </div>
     );
   }

File diff suppressed because it is too large
+ 146 - 133
frontend/src/pages/FileManagerPage.tsx


+ 31 - 29
frontend/src/pages/GroupsPage.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import {
   X,
   Plus,
@@ -25,6 +26,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 
 export function GroupsPage() {
   const navigate = useNavigate();
+  const { t } = useTranslation();
   const { hasPermission } = useAuth();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
@@ -74,7 +76,7 @@ export function GroupsPage() {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       setShowCreateModal(false);
       resetForm();
-      showToast('Group created successfully');
+      showToast(t('groups.toast.created'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -87,7 +89,7 @@ export function GroupsPage() {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       setEditingGroup(null);
       resetForm();
-      showToast('Group updated successfully');
+      showToast(t('groups.toast.updated'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -98,7 +100,7 @@ export function GroupsPage() {
     mutationFn: (id: number) => api.deleteGroup(id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
-      showToast('Group deleted successfully');
+      showToast(t('groups.toast.deleted'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -112,7 +114,7 @@ export function GroupsPage() {
 
   const handleCreate = () => {
     if (!formData.name.trim()) {
-      showToast('Please enter a group name', 'error');
+      showToast(t('groups.toast.enterGroupName'), 'error');
       return;
     }
     createMutation.mutate({
@@ -125,7 +127,7 @@ export function GroupsPage() {
   const handleUpdate = () => {
     if (!editingGroup) return;
     if (!formData.name.trim()) {
-      showToast('Please enter a group name', 'error');
+      showToast(t('groups.toast.enterGroupName'), 'error');
       return;
     }
     updateMutation.mutate({
@@ -206,7 +208,7 @@ export function GroupsPage() {
           <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>
+              <p className="text-white">{t('groups.noPermission')}</p>
             </div>
           </CardContent>
         </Card>
@@ -283,17 +285,17 @@ export function GroupsPage() {
           <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"
+            title={t('groups.backToSettings')}
           >
             <ArrowLeft className="w-5 h-5" />
           </button>
           <div>
             <h1 className="text-2xl font-bold text-white flex items-center gap-2">
               <Shield className="w-6 h-6 text-bambu-green" />
-              Group Management
+              {t('groups.title')}
             </h1>
             <p className="text-sm text-bambu-gray mt-1">
-              Manage permission groups for access control
+              {t('groups.subtitle')}
             </p>
           </div>
         </div>
@@ -305,7 +307,7 @@ export function GroupsPage() {
             }}
           >
             <Plus className="w-4 h-4" />
-            Create Group
+            {t('groups.createGroup')}
           </Button>
         )}
       </div>
@@ -335,34 +337,34 @@ export function GroupsPage() {
                     <h3 className="text-lg font-semibold text-white">{group.name}</h3>
                     {group.is_system && (
                       <span className="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
-                        System
+                        {t('groups.system')}
                       </span>
                     )}
                   </div>
                 </div>
               </CardHeader>
               <CardContent>
-                <p className="text-sm text-bambu-gray mb-4">{group.description || 'No description'}</p>
+                <p className="text-sm text-bambu-gray mb-4">{group.description || t('groups.noDescription')}</p>
                 <div className="flex items-center justify-between">
                   <div className="flex items-center gap-2 text-sm text-bambu-gray">
                     <Users className="w-4 h-4" />
-                    <span>{group.user_count} users</span>
+                    <span>{t('groups.usersCount', { count: group.user_count })}</span>
                   </div>
                   <div className="text-xs text-bambu-gray">
-                    {group.permissions.length} permissions
+                    {t('groups.permissionsCount', { count: group.permissions.length })}
                   </div>
                 </div>
                 <div className="flex gap-2 mt-4 pt-4 border-t border-bambu-dark-tertiary">
                   {hasPermission('groups:update') && (
                     <Button size="sm" variant="ghost" onClick={() => startEdit(group)}>
                       <Edit2 className="w-4 h-4" />
-                      Edit
+                      {t('groups.edit')}
                     </Button>
                   )}
                   {hasPermission('groups:delete') && !group.is_system && (
                     <Button size="sm" variant="ghost" onClick={() => handleDelete(group.id)}>
                       <Trash2 className="w-4 h-4" />
-                      Delete
+                      {t('groups.delete')}
                     </Button>
                   )}
                 </div>
@@ -391,7 +393,7 @@ export function GroupsPage() {
                 <div className="flex items-center gap-2">
                   <Shield className="w-5 h-5 text-bambu-green" />
                   <h2 className="text-lg font-semibold text-white">
-                    {editingGroup ? 'Edit Group' : 'Create Group'}
+                    {editingGroup ? t('groups.modal.editGroup') : t('groups.modal.createGroup')}
                   </h2>
                 </div>
                 <Button
@@ -411,7 +413,7 @@ export function GroupsPage() {
               <div className="space-y-4">
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Group Name
+                    {t('groups.form.groupName')}
                   </label>
                   <input
                     type="text"
@@ -419,27 +421,27 @@ export function GroupsPage() {
                     onChange={(e) => setFormData({ ...formData, name: e.target.value })}
                     disabled={editingGroup?.is_system}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50"
-                    placeholder="Enter group name"
+                    placeholder={t('groups.form.groupNamePlaceholder')}
                   />
                   {editingGroup?.is_system && (
-                    <p className="text-xs text-yellow-400 mt-1">System group names cannot be changed</p>
+                    <p className="text-xs text-yellow-400 mt-1">{t('groups.form.systemGroupWarning')}</p>
                   )}
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Description
+                    {t('groups.form.description')}
                   </label>
                   <textarea
                     value={formData.description}
                     onChange={(e) => setFormData({ ...formData, description: e.target.value })}
                     rows={2}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors resize-none"
-                    placeholder="Enter description (optional)"
+                    placeholder={t('groups.form.descriptionPlaceholder')}
                   />
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Permissions ({formData.permissions.length} selected)
+                    {t('groups.form.permissions', { count: formData.permissions.length })}
                   </label>
                   {renderPermissionEditor()}
                 </div>
@@ -453,7 +455,7 @@ export function GroupsPage() {
                     resetForm();
                   }}
                 >
-                  Cancel
+                  {t('groups.modal.cancel')}
                 </Button>
                 <Button
                   onClick={editingGroup ? handleUpdate : handleCreate}
@@ -462,12 +464,12 @@ export function GroupsPage() {
                   {(createMutation.isPending || updateMutation.isPending) ? (
                     <>
                       <Loader2 className="w-4 h-4 animate-spin" />
-                      {editingGroup ? 'Saving...' : 'Creating...'}
+                      {editingGroup ? t('groups.modal.saving') : t('groups.modal.creating')}
                     </>
                   ) : (
                     <>
                       <Save className="w-4 h-4" />
-                      {editingGroup ? 'Save Changes' : 'Create Group'}
+                      {editingGroup ? t('groups.modal.saveChanges') : t('groups.modal.createGroup')}
                     </>
                   )}
                 </Button>
@@ -480,9 +482,9 @@ export function GroupsPage() {
       {/* Delete Confirmation Modal */}
       {deleteGroupId !== null && (
         <ConfirmModal
-          title="Delete Group"
-          message="Are you sure you want to delete this group? Users in this group will lose these permissions."
-          confirmText="Delete Group"
+          title={t('groups.deleteModal.title')}
+          message={t('groups.deleteModal.message')}
+          confirmText={t('groups.deleteModal.confirm')}
           variant="danger"
           onConfirm={() => {
             deleteMutation.mutate(deleteGroupId);

+ 21 - 19
frontend/src/pages/LoginPage.tsx

@@ -1,6 +1,7 @@
 import { useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme } from '../contexts/ThemeContext';
@@ -8,6 +9,7 @@ import { HelpCircle, X } from 'lucide-react';
 
 export function LoginPage() {
   const navigate = useNavigate();
+  const { t } = useTranslation();
   const { login } = useAuth();
   const { showToast } = useToast();
   const { mode } = useTheme();
@@ -18,18 +20,18 @@ export function LoginPage() {
   const loginMutation = useMutation({
     mutationFn: () => login(username, password),
     onSuccess: () => {
-      showToast('Logged in successfully');
+      showToast(t('login.loginSuccess'));
       navigate('/');
     },
     onError: (error: Error) => {
-      showToast(error.message || 'Login failed', 'error');
+      showToast(error.message || t('login.loginFailed'), 'error');
     },
   });
 
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     if (!username || !password) {
-      showToast('Please enter username and password', 'error');
+      showToast(t('login.enterCredentials'), 'error');
       return;
     }
     loginMutation.mutate();
@@ -47,10 +49,10 @@ export function LoginPage() {
             />
           </div>
           <h2 className="text-3xl font-bold text-white">
-            Bambuddy Login
+            {t('login.title')}
           </h2>
           <p className="mt-2 text-sm text-bambu-gray">
-            Sign in to your account
+            {t('login.subtitle')}
           </p>
         </div>
 
@@ -58,7 +60,7 @@ export function LoginPage() {
           <div className="space-y-4">
             <div>
               <label htmlFor="username" className="block text-sm font-medium text-white mb-2">
-                Username
+                {t('login.username')}
               </label>
               <input
                 id="username"
@@ -67,14 +69,14 @@ export function LoginPage() {
                 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"
+                placeholder={t('login.usernamePlaceholder')}
                 autoComplete="username"
               />
             </div>
 
             <div>
               <label htmlFor="password" className="block text-sm font-medium text-white mb-2">
-                Password
+                {t('login.password')}
               </label>
               <input
                 id="password"
@@ -83,7 +85,7 @@ export function LoginPage() {
                 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"
+                placeholder={t('login.passwordPlaceholder')}
                 autoComplete="current-password"
               />
             </div>
@@ -95,7 +97,7 @@ export function LoginPage() {
               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'}
+              {loginMutation.isPending ? t('login.signingIn') : t('login.signIn')}
             </button>
           </div>
 
@@ -105,7 +107,7 @@ export function LoginPage() {
               onClick={() => setShowForgotPassword(true)}
               className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
             >
-              Forgot your password?
+              {t('login.forgotPassword')}
             </button>
           </div>
         </form>
@@ -124,7 +126,7 @@ export function LoginPage() {
             <div className="flex items-center justify-between mb-4">
               <div className="flex items-center gap-2">
                 <HelpCircle className="w-5 h-5 text-bambu-green" />
-                <h2 className="text-lg font-semibold text-white">Forgot Password</h2>
+                <h2 className="text-lg font-semibold text-white">{t('login.forgotPasswordTitle')}</h2>
               </div>
               <button
                 onClick={() => setShowForgotPassword(false)}
@@ -136,16 +138,16 @@ export function LoginPage() {
 
             <div className="space-y-4">
               <p className="text-bambu-gray">
-                If you've forgotten your password, please contact your system administrator to reset it.
+                {t('login.forgotPasswordMessage')}
               </p>
 
               <div className="bg-bambu-dark rounded-lg p-4 space-y-2">
-                <p className="text-sm text-white font-medium">How to reset your password:</p>
+                <p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
                 <ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
-                  <li>Contact your Bambuddy administrator</li>
-                  <li>Ask them to reset your password in User Management</li>
-                  <li>They can set a new temporary password for you</li>
-                  <li>Log in with the new password and change it in Settings</li>
+                  <li>{t('login.resetStep1')}</li>
+                  <li>{t('login.resetStep2')}</li>
+                  <li>{t('login.resetStep3')}</li>
+                  <li>{t('login.resetStep4')}</li>
                 </ol>
               </div>
 
@@ -153,7 +155,7 @@ export function LoginPage() {
                 onClick={() => setShowForgotPassword(false)}
                 className="w-full py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
               >
-                Got it
+                {t('login.gotIt')}
               </button>
             </div>
           </div>

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