diff --git a/backend/app/api/routes/notification_templates.py b/backend/app/api/routes/notification_templates.py index 55371e96f..1a8a5a63f 100644 --- a/backend/app/api/routes/notification_templates.py +++ b/backend/app/api/routes/notification_templates.py @@ -46,6 +46,11 @@ EVENT_NAMES = { # User management "user_created": "Welcome Email", "password_reset": "Password Reset", + # User email print notifications + "user_print_start": "User Print Started Email", + "user_print_complete": "User Print Completed Email", + "user_print_failed": "User Print Failed Email", + "user_print_stopped": "User Print Stopped Email", } diff --git a/backend/app/api/routes/print_queue.py b/backend/app/api/routes/print_queue.py index 0154b6583..f1781d96d 100644 --- a/backend/app/api/routes/print_queue.py +++ b/backend/app/api/routes/print_queue.py @@ -783,6 +783,17 @@ async def stop_queue_item( except Exception as e: logger.error("Error sending stop command for queue item %s: %s", item_id, e) + # Mark this printer as user-stopped BEFORE the first await so that if the + # MQTT on_print_complete callback fires during the db.commit() yield the flag + # is already set and the "failed" status will be correctly overridden to + # "cancelled" (preventing a spurious "print failed" notification). + try: + from backend.app.main import mark_printer_stopped_by_user + + mark_printer_stopped_by_user(printer_id) + except Exception as _mark_err: + logger.warning("Failed to mark printer %s as user-stopped: %s", printer_id, _mark_err) + # Update queue item status regardless - if printer is off, print is already stopped item.status = "cancelled" item.completed_at = datetime.now(timezone.utc) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 556d8acb7..631911df9 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -98,6 +98,7 @@ async def get_settings( "ha_enabled", "per_printer_mapping_expanded", "prometheus_enabled", + "user_notifications_enabled", "queue_drying_enabled", "queue_drying_block", "ambient_drying_enabled", diff --git a/backend/app/api/routes/user_notifications.py b/backend/app/api/routes/user_notifications.py new file mode 100644 index 000000000..ec34ae1cf --- /dev/null +++ b/backend/app/api/routes/user_notifications.py @@ -0,0 +1,107 @@ +"""API routes for user email notification preferences.""" + +import logging + +from fastapi import APIRouter, Depends, HTTPException, status +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.user import User +from backend.app.models.user_email_pref import UserEmailPreference +from backend.app.schemas.user_notifications import UserEmailPreferenceResponse, UserEmailPreferenceUpdate + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/user-notifications", tags=["user-notifications"]) + + +@router.get("/preferences", response_model=UserEmailPreferenceResponse) +async def get_user_email_preferences( + current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL), + db: AsyncSession = Depends(get_db), +): + """Get the current user's email notification preferences. + + Returns defaults (all enabled) if no preferences are saved yet. + """ + if current_user is None: + # Auth is disabled; no user context available, return defaults + return UserEmailPreferenceResponse( + notify_print_start=True, + notify_print_complete=True, + notify_print_failed=True, + notify_print_stopped=True, + ) + + result = await db.execute( + select(UserEmailPreference).where(UserEmailPreference.user_id == current_user.id) + ) + pref = result.scalar_one_or_none() + + if pref is None: + # Return defaults + return UserEmailPreferenceResponse( + notify_print_start=True, + notify_print_complete=True, + notify_print_failed=True, + notify_print_stopped=True, + ) + + return pref + + +@router.put("/preferences", response_model=UserEmailPreferenceResponse) +async def update_user_email_preferences( + data: UserEmailPreferenceUpdate, + current_user: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_USER_EMAIL), + db: AsyncSession = Depends(get_db), +): + """Update the current user's email notification preferences.""" + if current_user is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Authentication must be enabled to save user notification preferences", + ) + + if not current_user.email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User must have an email address to receive notifications", + ) + + result = await db.execute( + select(UserEmailPreference).where(UserEmailPreference.user_id == current_user.id) + ) + pref = result.scalar_one_or_none() + + if pref is None: + pref = UserEmailPreference( + user_id=current_user.id, + notify_print_start=data.notify_print_start, + notify_print_complete=data.notify_print_complete, + notify_print_failed=data.notify_print_failed, + notify_print_stopped=data.notify_print_stopped, + ) + db.add(pref) + else: + pref.notify_print_start = data.notify_print_start + pref.notify_print_complete = data.notify_print_complete + pref.notify_print_failed = data.notify_print_failed + pref.notify_print_stopped = data.notify_print_stopped + + await db.commit() + await db.refresh(pref) + + logger.info( + "Updated email notification preferences for user %s: start=%s, complete=%s, failed=%s, stopped=%s", + current_user.username, + pref.notify_print_start, + pref.notify_print_complete, + pref.notify_print_failed, + pref.notify_print_stopped, + ) + + return pref diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 611486f4e..fa3daa8fb 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -278,7 +278,8 @@ async def update_user( user.groups = list(groups) await db.commit() - await db.refresh(user) + result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups))) + user = result.scalar_one() return _user_to_response(user) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 8478d118d..1a8d67421 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -110,6 +110,7 @@ async def init_db(): spool_usage_history, spoolbuddy_device, user, + user_email_pref, virtual_printer, ) @@ -1455,6 +1456,52 @@ async def run_migrations(conn): for key in obsolete_keys: await conn.execute(text("DELETE FROM settings WHERE key = :key"), {"key": key}) + # Migration: Create user_email_preferences table for user-specific email notification settings + try: + await conn.execute( + text(""" + CREATE TABLE IF NOT EXISTS user_email_preferences ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + notify_print_start BOOLEAN NOT NULL DEFAULT 1, + notify_print_complete BOOLEAN NOT NULL DEFAULT 1, + notify_print_failed BOOLEAN NOT NULL DEFAULT 1, + notify_print_stopped BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """) + ) + await conn.execute( + text("CREATE INDEX IF NOT EXISTS ix_user_email_preferences_user_id ON user_email_preferences(user_id)") + ) + except OperationalError: + pass # Already applied + + # Legacy migration: Add notify_print_stopped column (for any existing partial tables) + try: + await conn.execute( + text( + "ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1" + ) + ) + except OperationalError: + pass # Column already exists or table created with full schema + + # Seed default settings keys that must exist on fresh install + default_settings = [ + ("advanced_auth_enabled", "false"), + ("smtp_auth_enabled", "true"), + ] + for key, value in default_settings: + try: + await conn.execute( + text("INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)"), + {"key": key, "value": value}, + ) + except OperationalError: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index 91183ce8d..a03d31bc7 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -97,7 +97,7 @@ class Permission(StrEnum): NOTIFICATIONS_CREATE = "notifications:create" NOTIFICATIONS_UPDATE = "notifications:update" NOTIFICATIONS_DELETE = "notifications:delete" - + NOTIFICATIONS_USER_EMAIL = "notifications:user_email" # Receive per-user print email notifications # Notification Templates NOTIFICATION_TEMPLATES_READ = "notification_templates:read" NOTIFICATION_TEMPLATES_UPDATE = "notification_templates:update" @@ -244,6 +244,7 @@ PERMISSION_CATEGORIES = { Permission.NOTIFICATIONS_CREATE, Permission.NOTIFICATIONS_UPDATE, Permission.NOTIFICATIONS_DELETE, + Permission.NOTIFICATIONS_USER_EMAIL, Permission.NOTIFICATION_TEMPLATES_READ, Permission.NOTIFICATION_TEMPLATES_UPDATE, ], @@ -381,6 +382,7 @@ DEFAULT_GROUPS = { Permission.NOTIFICATIONS_CREATE.value, Permission.NOTIFICATIONS_UPDATE.value, Permission.NOTIFICATIONS_DELETE.value, + Permission.NOTIFICATIONS_USER_EMAIL.value, Permission.NOTIFICATION_TEMPLATES_READ.value, Permission.NOTIFICATION_TEMPLATES_UPDATE.value, # External Links - full access diff --git a/backend/app/main.py b/backend/app/main.py index 15a63f1ee..a9ed449f0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -45,6 +45,7 @@ from backend.app.api.routes import ( support, system, updates, + user_notifications, users, virtual_printers, webhook, @@ -280,6 +281,28 @@ _timelapse_baselines: dict[int, set[str]] = {} # Track active bed cooldown monitoring tasks: {printer_id: asyncio.Task} _bed_cooldown_tasks: dict[int, asyncio.Task] = {} +# Track printers where the user explicitly stopped the print from the queue UI. +# When on_print_complete fires with status "failed" for these printers we treat it +# as "cancelled" (stopped by user) so the correct notification email is sent. +_user_stopped_printers: set[int] = set() + +# Track created_by_id for expected prints so the user email can be sent even when +# the archive itself doesn't have created_by_id set (e.g. library-file-based prints). +# {(printer_id, filename): created_by_id} +_expected_print_creators: dict[tuple[int, str], int] = {} + +# TTL for expected-print entries: evict registrations older than this to prevent +# unbounded growth when a print is registered but never starts (e.g. printer +# disconnect, app restart, print started from the printer panel). +_EXPECTED_PRINT_TTL_SECONDS: int = 2 * 60 * 60 # 2 hours + +# Registration timestamps used for TTL eviction: {(printer_id, filename): monotonic_time} +_expected_print_registered_at: dict[tuple[int, str], float] = {} + +# Cleanup loop interval +_EXPECTED_PRINT_CLEANUP_INTERVAL: int = 15 * 60 # 15 minutes +_expected_prints_cleanup_task: asyncio.Task | None = None + async def _get_plug_energy(plug, db) -> dict | None: """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT). @@ -308,7 +331,13 @@ async def _get_plug_energy(plug, db) -> dict | None: return await tasmota_service.get_energy(plug) -def register_expected_print(printer_id: int, filename: str, archive_id: int, ams_mapping: list[int] | None = None): +def register_expected_print( + printer_id: int, + filename: str, + archive_id: int, + ams_mapping: list[int] | None = None, + created_by_id: int | None = None, +): """Register an expected print from reprint/scheduled so we don't create duplicate archives.""" # Store with multiple filename variations to catch different naming patterns _expected_prints[(printer_id, filename)] = archive_id @@ -320,6 +349,21 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int, ams # Store AMS mapping for usage tracking at print completion if ams_mapping is not None: _print_ams_mappings[archive_id] = ams_mapping + # Store created_by_id so the user start email can be sent even when the archive + # itself has no created_by_id (e.g. library-file-based queue prints) + if created_by_id is not None: + _expected_print_creators[(printer_id, filename)] = created_by_id + if filename.endswith(".3mf"): + base = filename[:-4] + _expected_print_creators[(printer_id, base)] = created_by_id + _expected_print_creators[(printer_id, f"{base}.gcode")] = created_by_id + # Record registration time for TTL-based eviction + _registered_at = time.monotonic() + _expected_print_registered_at[(printer_id, filename)] = _registered_at + if filename.endswith(".3mf"): + base = filename[:-4] + _expected_print_registered_at[(printer_id, base)] = _registered_at + _expected_print_registered_at[(printer_id, f"{base}.gcode")] = _registered_at logging.getLogger(__name__).info( f"Registered expected print: printer={printer_id}, file={filename}, archive={archive_id}, ams_mapping={ams_mapping}" ) @@ -332,6 +376,15 @@ def _get_start_ams_mapping(data: dict, archive_id: int | None) -> list[int] | No stored_ams_mapping = _print_ams_mappings.get(archive_id) return stored_ams_mapping +def mark_printer_stopped_by_user(printer_id: int) -> None: + """Mark that the active print on this printer was stopped by the user from the queue UI. + + When on_print_complete fires with status 'failed' for a printer in this set we + reclassify it as 'cancelled' so the correct 'print stopped' notification is sent + rather than a 'print failed' notification. + """ + _user_stopped_printers.add(printer_id) + logging.getLogger(__name__).info("Marked printer %s as user-stopped from queue", printer_id) _last_status_broadcast: dict[int, str] = {} # Track printers where we've updated nozzle_count @@ -1035,10 +1088,55 @@ async def _send_print_start_notification( archive_data["image_data"] = image_data await notification_service.on_print_start(printer_id, printer_name, data, db, archive_data=archive_data) + + # Send user-specific email notification for print start + if archive_data and archive_data.get("created_by_id"): + await notification_service.send_user_print_email( + event_type="user_print_start", + created_by_id=archive_data["created_by_id"], + printer_name=printer_name, + filename=data.get("subtask_name") or data.get("filename", "Unknown"), + db=db, + ) except Exception as e: logger.warning("Notification on_print_start failed: %s", e) +async def _dispatch_user_print_email( + status: str, + created_by_id: int | None, + printer_name: str, + filename: str, + db, +) -> None: + """Send a user-specific print-completion email based on print status. + + Maps the normalised print status to the correct event type and delegates + to :meth:`NotificationService.send_user_print_email`. A single helper + avoids duplicating the ``if status == "completed" / elif "failed" / elif + "stopped"`` dispatch block at every call site. + + Does nothing if *created_by_id* is ``None``. + """ + if created_by_id is None: + return + if status == "completed": + event_type = "user_print_complete" + elif status == "failed": + event_type = "user_print_failed" + elif status in ("stopped", "aborted", "cancelled"): + event_type = "user_print_stopped" + else: + return + await notification_service.send_user_print_email( + event_type=event_type, + created_by_id=created_by_id, + printer_name=printer_name, + filename=filename, + db=db, + ) + + def _load_objects_from_archive(archive, printer_id: int, logger) -> None: """Extract printable objects from an archive's 3MF file and store in printer state.""" try: @@ -1067,6 +1165,9 @@ async def on_print_start(printer_id: int, data: dict): logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys())) + # Clear any stale user-stopped flag from previous print cycles + _user_stopped_printers.discard(printer_id) + # Cancel any active bed cooldown task for this printer existing_task = _bed_cooldown_tasks.pop(printer_id, None) if existing_task and not existing_task.done(): @@ -1222,7 +1323,36 @@ async def on_print_start(printer_id: int, data: dict): f"[CALLBACK] Skipping archive - printer: {printer is not None}, auto_archive: {printer.auto_archive if printer else 'N/A'}" ) if not notification_sent: - await _send_print_start_notification(printer_id, data, logger=logger) + # Even with auto-archive disabled, try to recover created_by_id from + # a registered expected print (e.g. a library-file queue item) so the + # user start email can still be sent. + _fn = data.get("filename", "") + _sn = data.get("subtask_name", "") + _no_archive_creator_keys: list[tuple[int, str]] = [] + if _sn: + _no_archive_creator_keys += [ + (printer_id, _sn), + (printer_id, f"{_sn}.3mf"), + (printer_id, f"{_sn}.gcode.3mf"), + ] + if _fn: + _base_fn = _fn.split("/")[-1] if "/" in _fn else _fn + _no_archive_creator_keys.append((printer_id, _base_fn)) + _no_archive_base = _base_fn.replace(".gcode", "").replace(".3mf", "") + _no_archive_creator_keys += [ + (printer_id, _no_archive_base), + (printer_id, f"{_no_archive_base}.3mf"), + ] + _no_archive_creator: int | None = None + for _key in _no_archive_creator_keys: + # Clean up all dicts for every key to avoid memory leaks + _expected_prints.pop(_key, None) + _expected_print_registered_at.pop(_key, None) + popped_creator = _expected_print_creators.pop(_key, None) + if _no_archive_creator is None: + _no_archive_creator = popped_creator + _creator_data = {"created_by_id": _no_archive_creator} if _no_archive_creator else None + await _send_print_start_notification(printer_id, data, _creator_data, logger) return # Get the filename and subtask_name @@ -1264,10 +1394,12 @@ async def on_print_start(printer_id: int, data: dict): expected_archive_id = None for key in expected_keys: expected_archive_id = _expected_prints.pop(key, None) + _expected_print_registered_at.pop(key, None) if expected_archive_id: # Clean up other possible keys for this print for other_key in expected_keys: _expected_prints.pop(other_key, None) + _expected_print_registered_at.pop(other_key, None) break if expected_archive_id: @@ -1320,7 +1452,19 @@ async def on_print_start(printer_id: int, data: dict): # Send notification with archive data (reprint/scheduled) if not notification_sent: - archive_data = {"print_time_seconds": archive.print_time_seconds} + # Use archive's created_by_id; fall back to the creator registered via + # register_expected_print (handles library-file-based queue items where + # the freshly-created archive has no created_by_id yet). + # Pop ALL matching keys so no stale entries remain in the dict. + fallback_creator = None + for key in expected_keys: + popped = _expected_print_creators.pop(key, None) + if fallback_creator is None: + fallback_creator = popped + archive_data = { + "print_time_seconds": archive.print_time_seconds, + "created_by_id": archive.created_by_id or fallback_creator, + } await _send_print_start_notification(printer_id, data, archive_data, logger) # Extract printable objects from the archived 3MF file @@ -1401,7 +1545,10 @@ async def on_print_start(printer_id: int, data: dict): logger.warning("Failed to record starting energy for existing archive: %s", e) # Send notification with archive data (existing archive) if not notification_sent: - archive_data = {"print_time_seconds": existing_archive.print_time_seconds} + archive_data = { + "print_time_seconds": existing_archive.print_time_seconds, + "created_by_id": existing_archive.created_by_id, + } await _send_print_start_notification(printer_id, data, archive_data, logger) # Extract printable objects from the archived 3MF file _load_objects_from_archive(existing_archive, printer_id, logger) @@ -1749,7 +1896,10 @@ async def on_print_start(printer_id: int, data: dict): # Send notification with archive data (new archive created) if not notification_sent: - archive_data = {"print_time_seconds": archive.print_time_seconds} + archive_data = { + "print_time_seconds": archive.print_time_seconds, + "created_by_id": archive.created_by_id, + } await _send_print_start_notification(printer_id, data, archive_data, logger) # Extract printable objects for skip object functionality @@ -2060,6 +2210,20 @@ async def on_print_complete(printer_id: int, data: dict): # Clear current print user tracking (Issue #206) printer_manager.clear_current_print_user(printer_id) + # If the user explicitly stopped this print from the queue UI the printer will + # report "failed" or "aborted" via MQTT. Override that to "cancelled" so the + # correct "print stopped" notification/email is sent instead of a failure alert. + _raw_status = data.get("status", "completed") + if printer_id in _user_stopped_printers and _raw_status in ("failed", "aborted"): + logger.info( + "[CALLBACK] Overriding status '%s' -> 'cancelled' for printer %s " + "(print was stopped from queue by user)", + _raw_status, + printer_id, + ) + data = {**data, "status": "cancelled"} + _user_stopped_printers.discard(printer_id) + # MQTT relay - publish print complete try: printer_info = printer_manager.get_printer(printer_id) @@ -2421,6 +2585,76 @@ async def on_print_complete(printer_id: int, data: dict): if not archive_id: logger.warning("Could not find archive for print complete: filename=%s, subtask=%s", filename, subtask_name) + + # Still send print-complete/failed/stopped notifications even without an archive. + # Try to enrich with queue/library-file data so user-specific emails work too. + async def _notify_no_archive(): + try: + async with async_session() as db: + from backend.app.models.library import LibraryFile + from backend.app.models.print_queue import PrintQueueItem + from backend.app.models.printer import Printer + + result = await db.execute(select(Printer).where(Printer.id == printer_id)) + printer_obj = result.scalar_one_or_none() + p_name = printer_obj.name if printer_obj else f"Printer {printer_id}" + + # Try to find the most-recent queue item for this printer so we can + # recover created_by_id and estimated print time. + # NOTE: By the time this task runs the queue item status has already + # been updated to a terminal state (completed/failed/cancelled), so + # we look for recently-completed items (within the last 5 minutes). + no_archive_data: dict | None = None + try: + cutoff = datetime.now(timezone.utc) - timedelta(minutes=5) + q_result = await db.execute( + select(PrintQueueItem) + .where(PrintQueueItem.printer_id == printer_id) + .where(PrintQueueItem.status.in_(["completed", "failed", "cancelled"])) + .where(PrintQueueItem.completed_at >= cutoff) + .order_by(PrintQueueItem.completed_at.desc()) + .limit(1) + ) + queue_item = q_result.scalar_one_or_none() + if queue_item: + no_archive_data = {"created_by_id": queue_item.created_by_id} + # Pull estimated time from library file when available + if queue_item.library_file_id: + lib_result = await db.execute( + select(LibraryFile).where(LibraryFile.id == queue_item.library_file_id) + ) + lib_file = lib_result.scalar_one_or_none() + if lib_file and lib_file.print_time_seconds: + no_archive_data["print_time_seconds"] = lib_file.print_time_seconds + except Exception as lookup_err: + logger.debug( + "[NOTIFY-BG] Could not look up queue item for no-archive notification: %s", lookup_err + ) + + ps = data.get("status", "completed") + logger.info( + "[NOTIFY-BG] Sending notification without archive: printer=%s, status=%s", printer_id, ps + ) + await notification_service.on_print_complete( + printer_id, p_name, ps, data, db, archive_data=no_archive_data + ) + + # Send user-specific email if we have a created_by_id + if no_archive_data and no_archive_data.get("created_by_id"): + raw_filename = data.get("subtask_name") or data.get("filename", "Unknown") + await _dispatch_user_print_email( + ps, + no_archive_data["created_by_id"], + p_name, + raw_filename, + db, + ) + logger.info("[NOTIFY-BG] Completed (no-archive path)") + except Exception as e: + logger.warning("[NOTIFY-BG] Failed to send notification without archive: %s", e, exc_info=True) + + task = asyncio.create_task(_notify_no_archive()) + task.add_done_callback(lambda t: None) return log_timing("Archive lookup") @@ -2760,6 +2994,7 @@ async def on_print_complete(printer_id: int, data: dict): "print_time_seconds": archive.print_time_seconds, "actual_filament_grams": archive.filament_used_grams, "failure_reason": archive.failure_reason, + "created_by_id": archive.created_by_id, } # Scale filament usage for partial prints @@ -2822,6 +3057,19 @@ async def on_print_complete(printer_id: int, data: dict): await notification_service.on_print_complete( printer_id, printer_name, print_status, data, db, archive_data=archive_data ) + + # Send user-specific email notification + if archive_data: + created_by_id = archive_data.get("created_by_id") + raw_filename = data.get("subtask_name") or data.get("filename", "Unknown") + await _dispatch_user_print_email( + print_status, + created_by_id, + printer_name, + raw_filename, + db, + ) + logger.info("[NOTIFY-BG] Completed") except Exception as e: logger.warning("[NOTIFY-BG] Failed: %s", e) @@ -3315,6 +3563,74 @@ def stop_camera_cleanup(): logging.getLogger(__name__).info("Camera stream cleanup stopped") +# --------------------------------------------------------------------------- +# Expected-print TTL eviction +# --------------------------------------------------------------------------- + + +def _evict_stale_expected_prints() -> None: + """Remove entries from _expected_prints / _expected_print_creators that are + older than _EXPECTED_PRINT_TTL_SECONDS. + + This prevents unbounded growth when a print is registered (via + register_expected_print) but on_print_start never fires — e.g. because the + printer disconnects, the app restarts, or the print is started directly from + the printer panel without going through the queue. + """ + # Use monotonic time so the TTL is unaffected by system clock adjustments + # (e.g. NTP sync, DST changes). + cutoff = time.monotonic() - _EXPECTED_PRINT_TTL_SECONDS + stale_keys = [k for k, t in _expected_print_registered_at.items() if t < cutoff] + if not stale_keys: + return + + evicted_archive_ids: set[int] = set() + for key in stale_keys: + archive_id = _expected_prints.pop(key, None) + if archive_id is not None: + evicted_archive_ids.add(archive_id) + _expected_print_creators.pop(key, None) + _expected_print_registered_at.pop(key, None) + + # Also clean up _print_ams_mappings for archive_ids that have no remaining + # live keys in _expected_prints (i.e. all variants were just evicted). + live_archive_ids = set(_expected_prints.values()) + for archive_id in evicted_archive_ids: + if archive_id not in live_archive_ids: + _print_ams_mappings.pop(archive_id, None) + + logging.getLogger(__name__).info( + "Evicted %d stale expected-print entries (TTL=%ds)", len(stale_keys), _EXPECTED_PRINT_TTL_SECONDS + ) + + +async def _expected_prints_cleanup_loop() -> None: + """Background task: periodically evict stale expected-print entries.""" + while True: + try: + _evict_stale_expected_prints() + except asyncio.CancelledError: + raise + except Exception as e: + logging.getLogger(__name__).warning("Expected prints cleanup failed: %s", e) + await asyncio.sleep(_EXPECTED_PRINT_CLEANUP_INTERVAL) + + +def start_expected_prints_cleanup() -> None: + global _expected_prints_cleanup_task + if _expected_prints_cleanup_task is None: + _expected_prints_cleanup_task = asyncio.create_task(_expected_prints_cleanup_loop()) + logging.getLogger(__name__).info("Expected prints cleanup started") + + +def stop_expected_prints_cleanup() -> None: + global _expected_prints_cleanup_task + if _expected_prints_cleanup_task: + _expected_prints_cleanup_task.cancel() + _expected_prints_cleanup_task = None + logging.getLogger(__name__).info("Expected prints cleanup stopped") + + @asynccontextmanager async def lifespan(app: FastAPI): # Startup @@ -3469,6 +3785,10 @@ async def lifespan(app: FastAPI): # Start camera stream orphan cleanup start_camera_cleanup() + # Start expected-print TTL eviction (prevents memory leak when prints are + # registered but on_print_start never fires) + start_expected_prints_cleanup() + # Initialize virtual printer manager and sync from DB from backend.app.services.virtual_printer import virtual_printer_manager @@ -3491,6 +3811,7 @@ async def lifespan(app: FastAPI): stop_runtime_tracking() stop_spoolbuddy_watchdog() stop_camera_cleanup() + stop_expected_prints_cleanup() printer_manager.disconnect_all() await close_spoolman_client() @@ -3686,6 +4007,7 @@ app.include_router(background_dispatch_routes.router, prefix=app_settings.api_pr app.include_router(kprofiles.router, prefix=app_settings.api_prefix) app.include_router(notifications.router, prefix=app_settings.api_prefix) app.include_router(notification_templates.router, prefix=app_settings.api_prefix) +app.include_router(user_notifications.router, prefix=app_settings.api_prefix) app.include_router(spoolman.router, prefix=app_settings.api_prefix) app.include_router(updates.router, prefix=app_settings.api_prefix) app.include_router(maintenance.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py index 4f6fc1584..40c8f920f 100644 --- a/backend/app/models/notification_template.py +++ b/backend/app/models/notification_template.py @@ -170,4 +170,29 @@ DEFAULT_TEMPLATES = [ "title_template": "{app_name} - Password Reset", "body_template": "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}", }, + # User email notification templates (sent to the print job owner) + { + "event_type": "user_print_start", + "name": "User Print Started", + "title_template": "Your Print Has Started", + "body_template": "Hello {username},\n\nYour print job has started on {printer}.\n\nFile: {filename}\n\nYou will be notified when it completes.", + }, + { + "event_type": "user_print_complete", + "name": "User Print Completed", + "title_template": "Your Print Is Complete", + "body_template": "Hello {username},\n\nYour print job has completed on {printer}.\n\nFile: {filename}", + }, + { + "event_type": "user_print_failed", + "name": "User Print Failed", + "title_template": "Your Print Has Failed", + "body_template": "Hello {username},\n\nYour print job has failed on {printer}.\n\nFile: {filename}", + }, + { + "event_type": "user_print_stopped", + "name": "User Print Stopped", + "title_template": "Your Print Has Been Stopped", + "body_template": "Hello {username},\n\nYour print job was stopped on {printer}.\n\nFile: {filename}", + }, ] diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 28d3c2f48..d720a2bf8 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -10,6 +10,7 @@ from backend.app.core.database import Base if TYPE_CHECKING: from backend.app.models.group import Group + from backend.app.models.user_email_pref import UserEmailPreference class User(Base): @@ -45,6 +46,15 @@ class User(Base): lazy="selectin", ) + # Relationship to email notification preferences + email_preferences: Mapped[UserEmailPreference | None] = relationship( + "UserEmailPreference", + back_populates="user", + uselist=False, + cascade="all, delete-orphan", + lazy="select", + ) + @property def is_admin(self) -> bool: """Check if user is an admin. diff --git a/backend/app/models/user_email_pref.py b/backend/app/models/user_email_pref.py new file mode 100644 index 000000000..58aa74e60 --- /dev/null +++ b/backend/app/models/user_email_pref.py @@ -0,0 +1,31 @@ +"""User email notification preference model.""" + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from backend.app.core.database import Base + + +class UserEmailPreference(Base): + """Stores per-user email notification preferences for their own print jobs.""" + + __tablename__ = "user_email_preferences" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True + ) + + # Print lifecycle notifications (only for jobs submitted by this user) + notify_print_start: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + notify_print_complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + notify_print_failed: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + notify_print_stopped: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + # Relationship + user: Mapped["User"] = relationship(back_populates="email_preferences") diff --git a/backend/app/schemas/notification_template.py b/backend/app/schemas/notification_template.py index a9494838c..2e6f3bb8f 100644 --- a/backend/app/schemas/notification_template.py +++ b/backend/app/schemas/notification_template.py @@ -81,6 +81,11 @@ EVENT_VARIABLES: dict[str, list[str]] = { # User management notifications "user_created": ["username", "password", "login_url", "app_name", "timestamp"], "password_reset": ["username", "password", "login_url", "app_name", "timestamp"], + # User email print notifications + "user_print_start": ["username", "printer", "filename", "timestamp", "app_name"], + "user_print_complete": ["username", "printer", "filename", "timestamp", "app_name"], + "user_print_failed": ["username", "printer", "filename", "timestamp", "app_name"], + "user_print_stopped": ["username", "printer", "filename", "timestamp", "app_name"], } # Sample data for previewing templates @@ -252,6 +257,35 @@ SAMPLE_DATA: dict[str, dict[str, str]] = { "app_name": "Bambuddy", "timestamp": "2024-01-15 14:30", }, + # User email print notifications + "user_print_start": { + "username": "john_doe", + "printer": "Bambu X1C", + "filename": "Benchy.3mf", + "timestamp": "2024-01-15 14:30", + "app_name": "Bambuddy", + }, + "user_print_complete": { + "username": "john_doe", + "printer": "Bambu X1C", + "filename": "Benchy.3mf", + "timestamp": "2024-01-15 15:48", + "app_name": "Bambuddy", + }, + "user_print_failed": { + "username": "john_doe", + "printer": "Bambu X1C", + "filename": "Benchy.3mf", + "timestamp": "2024-01-15 15:15", + "app_name": "Bambuddy", + }, + "user_print_stopped": { + "username": "john_doe", + "printer": "Bambu X1C", + "filename": "Benchy.3mf", + "timestamp": "2024-01-15 15:15", + "app_name": "Bambuddy", + }, } diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index e36eb706c..56e08dba9 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -178,6 +178,12 @@ class AppSettings(BaseModel): description="Low stock threshold percentage (%) for inventory filtering and display", ) + # User email notifications (requires Advanced Authentication) + user_notifications_enabled: bool = Field( + default=True, + description="Enable user email notifications for print job events (requires Advanced Authentication)", + ) + class AppSettingsUpdate(BaseModel): """Schema for updating settings (all fields optional).""" @@ -245,3 +251,4 @@ class AppSettingsUpdate(BaseModel): prometheus_enabled: bool | None = None prometheus_token: str | None = None low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9) + user_notifications_enabled: bool | None = None diff --git a/backend/app/schemas/user_notifications.py b/backend/app/schemas/user_notifications.py new file mode 100644 index 000000000..7a8b8cab1 --- /dev/null +++ b/backend/app/schemas/user_notifications.py @@ -0,0 +1,24 @@ +"""Schemas for user email notification preferences.""" + +from pydantic import BaseModel + + +class UserEmailPreferenceResponse(BaseModel): + """Response schema for user email notification preferences.""" + + notify_print_start: bool + notify_print_complete: bool + notify_print_failed: bool + notify_print_stopped: bool + + class Config: + from_attributes = True + + +class UserEmailPreferenceUpdate(BaseModel): + """Update schema for user email notification preferences.""" + + notify_print_start: bool + notify_print_complete: bool + notify_print_failed: bool + notify_print_stopped: bool diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 455d1f10e..94f0db748 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -8,6 +8,7 @@ import re import secrets import smtplib import string +from datetime import datetime from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import Any @@ -514,3 +515,68 @@ async def create_password_reset_email_from_template( # Fallback to hardcoded template logger.warning("No password reset email template found in database, using default") return create_password_reset_email(username, password, login_url) + + +async def send_user_print_notification( + db: AsyncSession, + event_type: str, + user_email: str, + username: str, + variables: dict, +) -> None: + """Send a print notification email to a user using Advanced Auth SMTP settings. + + Args: + db: Database session + event_type: One of 'user_print_start', 'user_print_complete', 'user_print_failed', 'user_print_stopped' + user_email: Recipient email address + username: Username of the recipient + variables: Template variables (printer, filename, etc.) + """ + # Check that advanced auth is enabled (SMTP settings must be configured) + smtp_settings = await get_smtp_settings(db) + if not smtp_settings: + logger.warning("Cannot send user print notification: SMTP settings not configured") + return + + # Get the template + template = await get_notification_template(db, event_type) + if template is None: + logger.warning("No template found for event type: %s", event_type) + return + + # Add common variables (username, timestamp, app_name) merged with caller-supplied variables + all_variables = { + "username": username, + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), + "app_name": "Bambuddy", + **variables, + } + + subject = render_template(template.title_template, all_variables) + text_body = render_template(template.body_template, all_variables) + + # Build HTML body — content comes entirely from the database template + escaped_text_body = html.escape(text_body).replace("\n", "
\n") + html_body = f""" + + + + + + +
+

{html.escape(subject)}

+
+
+
{escaped_text_body}
+
+ + +""" + + try: + send_email(smtp_settings, user_email, subject, text_body, html_body) + logger.info("Sent %s notification email to %s", event_type, user_email) + except Exception as e: + logger.error("Failed to send %s notification to %s: %s", event_type, user_email, e) diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index f9b82cc2d..340ff9c3f 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1179,6 +1179,120 @@ class NotificationService: """Clear the template cache. Call this when templates are updated.""" self._template_cache.clear() + async def send_user_print_email( + self, + event_type: str, + created_by_id: int | None, + printer_name: str, + filename: str, + db: AsyncSession, + ) -> None: + """Send a print event email notification to the user who submitted the job. + + Args: + event_type: 'user_print_start', 'user_print_complete', 'user_print_failed', or 'user_print_stopped' + created_by_id: User ID who submitted the print job (from archive) + printer_name: Name of the printer + filename: Raw filename or subtask name + db: Database session + """ + if created_by_id is None: + logger.debug("[EMAIL] Skipping user print email (%s): no created_by_id", event_type) + return + + try: + # Check if advanced auth is enabled - required for user email notifications + from backend.app.models.settings import Settings + + result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled")) + setting = result.scalar_one_or_none() + if not setting or setting.value.lower() != "true": + logger.debug("[EMAIL] Skipping user print email (%s): advanced_auth not enabled", event_type) + return + + # Check if user notifications are enabled (admin-controlled toggle) + notif_enabled_result = await db.execute( + select(Settings).where(Settings.key == "user_notifications_enabled") + ) + notif_enabled_setting = notif_enabled_result.scalar_one_or_none() + if notif_enabled_setting and notif_enabled_setting.value.lower() == "false": + logger.debug("[EMAIL] Skipping user print email (%s): user_notifications_enabled is false", event_type) + return + + # Check SMTP settings are configured - required for sending emails + from backend.app.services.email_service import get_smtp_settings, send_user_print_notification + + smtp_settings = await get_smtp_settings(db) + if not smtp_settings: + logger.debug("[EMAIL] Skipping user print email (%s): SMTP settings not configured", event_type) + return + + # Load user preferences + from backend.app.models.user import User + from backend.app.models.user_email_pref import UserEmailPreference + + user_result = await db.execute(select(User).where(User.id == created_by_id)) + user = user_result.scalar_one_or_none() + if user is None or not user.email: + logger.debug( + "[EMAIL] Skipping user print email (%s): user %s not found or has no email address", + event_type, + created_by_id, + ) + return + + # Load user's notification preferences + pref_result = await db.execute( + select(UserEmailPreference).where(UserEmailPreference.user_id == created_by_id) + ) + pref = pref_result.scalar_one_or_none() + + # Determine if this event type should be sent + should_send = False + if event_type == "user_print_start": + should_send = pref is None or pref.notify_print_start + elif event_type == "user_print_complete": + should_send = pref is None or pref.notify_print_complete + elif event_type == "user_print_failed": + should_send = pref is None or pref.notify_print_failed + elif event_type == "user_print_stopped": + should_send = pref is None or pref.notify_print_stopped + + if not should_send: + logger.debug( + "[EMAIL] Skipping user print email (%s): user %s has notifications disabled for this event", + event_type, + created_by_id, + ) + return + + logger.info( + "[EMAIL] Sending user print email: event=%s, user=%s (%s), printer=%s, file=%s", + event_type, + user.username, + user.email, + printer_name, + filename, + ) + + # Build variables + variables = { + "printer": printer_name, + "filename": self._clean_filename(filename), + } + + # Send the email + await send_user_print_notification( + db=db, + event_type=event_type, + user_email=user.email, + username=user.username, + variables=variables, + ) + logger.info("[EMAIL] User print email sent: event=%s → %s", event_type, user.email) + except Exception as e: + logger.warning("Failed to send user print email notification: %s", e, exc_info=True) + # ==================== Queue Notifications ==================== async def on_queue_job_added( diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index e5a50cc6a..058068753 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -1517,6 +1517,7 @@ class PrintScheduler: printer_id=item.printer_id, source_file=file_path, original_filename=filename, + created_by_id=item.created_by_id, ) if archive: item.archive_id = archive.id @@ -1650,7 +1651,13 @@ class PrintScheduler: if archive: from backend.app.main import register_expected_print - register_expected_print(item.printer_id, remote_filename, archive.id, ams_mapping=ams_mapping) + register_expected_print( + item.printer_id, + remote_filename, + archive.id, + ams_mapping=ams_mapping, + created_by_id=item.created_by_id, + ) # IMPORTANT: Set status to "printing" BEFORE sending the print command. # This prevents phantom reprints if the backend crashes/restarts after the diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4a6db4f97..4b117edcf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import InventoryPage from './pages/InventoryPage'; import { SystemInfoPage } from './pages/SystemInfoPage'; import { LoginPage } from './pages/LoginPage'; import { SetupPage } from './pages/SetupPage'; +import { NotificationsPage } from './pages/NotificationsPage'; import { useWebSocket } from './hooks/useWebSocket'; import { ThemeProvider } from './contexts/ThemeContext'; import { ToastProvider } from './contexts/ToastContext'; @@ -146,6 +147,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index fc5d930ae..62e7492a0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -860,6 +860,8 @@ export interface AppSettings { bed_cooled_threshold: number; // Inventory low stock threshold low_stock_threshold: number; + // User email notifications toggle + user_notifications_enabled: boolean; } export type AppSettingsUpdate = Partial; @@ -2051,7 +2053,7 @@ export type Permission = | 'camera:view' | 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete' | 'kprofiles:read' | 'kprofiles:create' | 'kprofiles:update' | 'kprofiles:delete' - | 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete' + | 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete' | 'notifications:user_email' | 'notification_templates:read' | 'notification_templates:update' | 'external_links:read' | 'external_links:create' | 'external_links:update' | 'external_links:delete' | 'discovery:scan' @@ -2115,6 +2117,14 @@ export interface PermissionsListResponse { all_permissions: Permission[]; } +// User email notification preferences +export interface UserEmailPreferences { + notify_print_start: boolean; + notify_print_complete: boolean; + notify_print_failed: boolean; + notify_print_stopped: boolean; +} + // Auth types export interface LoginRequest { username: string; @@ -2294,6 +2304,15 @@ export const api = { body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), }), + // User Email Notifications + getUserEmailPreferences: () => + request('/user-notifications/preferences'), + updateUserEmailPreferences: (data: UserEmailPreferences) => + request('/user-notifications/preferences', { + method: 'PUT', + body: JSON.stringify(data), + }), + // Groups getPermissions: () => request('/groups/permissions'), getGroups: () => request('/groups/'), diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 6428c5786..8646690e7 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, type LucideIcon } from 'lucide-react'; +import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, type LucideIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; @@ -25,6 +25,7 @@ interface NavItem { } export const defaultNavItems: NavItem[] = [ + // Primary workflow items { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' }, { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' }, { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' }, @@ -34,6 +35,8 @@ export const defaultNavItems: NavItem[] = [ { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' }, { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' }, { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' }, + // User-account features: kept adjacent to Settings intentionally + { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' }, { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' }, ]; @@ -114,6 +117,14 @@ export function Layout() { staleTime: 5 * 60 * 1000, // 5 minutes }); + // Check advanced auth status for conditional nav items + const { data: advancedAuthStatus } = useQuery({ + queryKey: ['advancedAuthStatus'], + queryFn: api.getAdvancedAuthStatus, + staleTime: 5 * 60 * 1000, // 5 minutes + enabled: authEnabled, + }); + const { data: updateCheck } = useQuery({ queryKey: ['updateCheck'], queryFn: api.checkForUpdates, @@ -234,10 +245,15 @@ export function Layout() { inventory: 'inventory:read', files: 'library:read', settings: 'settings:read', + notifications: 'notifications:user_email', }; - const isHidden = (id: string) => - authEnabled && id in navPermissions && !hasPermission(navPermissions[id]); + const isHidden = (id: string) => { + if (authEnabled && id in navPermissions && !hasPermission(navPermissions[id])) return true; + // notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting + if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true; + return false; + }; // Add items in stored order for (const id of sidebarOrder) { diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index f186d4c6e..4a23113d5 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -10,6 +10,7 @@ export default { projects: 'Projekte', inventory: 'Filament', files: 'Dateimanager', + notifications: 'Benachrichtigungen', settings: 'Einstellungen', system: 'System', collapseSidebar: 'Seitenleiste einklappen', @@ -1286,6 +1287,9 @@ export default { notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen', bedCooledThreshold: 'Bett-Abkühlung Schwellenwert', bedCooledThresholdDescription: 'Temperatur, unter der das Bett nach einem Druck als abgekühlt gilt', + userNotificationsEnabled: 'Benutzerbenachrichtigungen', + userNotificationsEnabledDescription: 'Aktiviert das Benutzerbenachrichtigungsmenü und E-Mail-Benachrichtigungen für Druckereignisse. Erfordert Erweiterte Authentifizierung.', + userNotificationsDisabledHint: 'Erweiterte Authentifizierung aktivieren, um Benutzerbenachrichtigungen zu verwenden.', notificationProviders: 'Benachrichtigungsanbieter', addProvider: 'Anbieter hinzufügen', editProvider: 'Anbieter bearbeiten', @@ -3927,6 +3931,26 @@ export default { maintenance_due: 'Wartung fällig', test: 'Test', }, + // User email notification preferences + userEmail: { + title: 'Benachrichtigungen', + emailNotifications: 'E-Mail-Benachrichtigungen', + emailNotificationsDesc: 'Erhalten Sie E-Mail-Benachrichtigungen für Ihre eigenen Druckaufträge. E-Mails werden über die in der erweiterten Authentifizierung konfigurierten SMTP-Einstellungen gesendet.', + sendingTo: 'Benachrichtigungen werden gesendet an', + noEmailWarning: 'Ihr Konto hat keine E-Mail-Adresse. Wenden Sie sich an einen Administrator, um eine hinzuzufügen.', + printJobNotifications: 'Druckauftrags-Benachrichtigungen', + printJobNotificationsDesc: 'Wählen Sie aus, welche Ereignisse E-Mail-Benachrichtigungen für von Ihnen gesendete Druckaufträge auslösen.', + printJobStarts: 'Druckauftrag startet', + printJobStartsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag beginnt.', + printJobFinishes: 'Druckauftrag fertig', + printJobFinishesDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag erfolgreich abgeschlossen wurde.', + printErrors: 'Druckfehler', + printErrorsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag fehlschlägt oder auf einen Fehler stößt.', + printJobStops: 'Druckauftrag gestoppt', + printJobStopsDesc: 'Benachrichtigt werden, wenn Ihr Druckauftrag abgebrochen oder gestoppt wird.', + saveSuccess: 'Benachrichtigungseinstellungen gespeichert.', + saveError: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.', + }, }, // Rich Text Editor diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d481900a1..aaf4124cf 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -10,6 +10,7 @@ export default { projects: 'Projects', inventory: 'Filament', files: 'File Manager', + notifications: 'Notifications', settings: 'Settings', system: 'System', collapseSidebar: 'Collapse sidebar', @@ -1286,6 +1287,9 @@ export default { notificationLanguageDescription: 'Language for push notifications', bedCooledThreshold: 'Bed Cooled Threshold', bedCooledThresholdDescription: 'Temperature below which the bed is considered cooled after a print', + userNotificationsEnabled: 'User Notifications', + userNotificationsEnabledDescription: 'Enable the user notifications menu and email notifications for print job events. Requires Advanced Authentication.', + userNotificationsDisabledHint: 'Enable Advanced Authentication to use user notifications.', notificationProviders: 'Notification Providers', addProvider: 'Add Provider', editProvider: 'Edit Provider', @@ -3932,6 +3936,26 @@ export default { maintenance_due: 'Maintenance Due', test: 'Test', }, + // User email notification preferences + userEmail: { + title: 'Notifications', + emailNotifications: 'Email Notifications', + emailNotificationsDesc: 'Receive email notifications for your own print jobs. Emails are sent using the system SMTP settings configured in Advanced Authentication.', + sendingTo: 'Notifications will be sent to', + noEmailWarning: 'Your account does not have an email address. Contact an administrator to add one.', + printJobNotifications: 'Print Job Notifications', + printJobNotificationsDesc: 'Choose which events trigger email notifications for print jobs you submit.', + printJobStarts: 'Print Job Starts', + printJobStartsDesc: 'Get notified when your print job begins.', + printJobFinishes: 'Print Job Finishes', + printJobFinishesDesc: 'Get notified when your print job completes successfully.', + printErrors: 'Print Errors', + printErrorsDesc: 'Get notified when your print job fails or encounters an error.', + printJobStops: 'Print Job Stops', + printJobStopsDesc: 'Get notified when your print job is cancelled or stopped.', + saveSuccess: 'Notification preferences saved.', + saveError: 'Failed to save notification preferences.', + }, }, // Rich Text Editor diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index f6048b070..f8166c447 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -10,6 +10,7 @@ export default { projects: 'Projets', inventory: 'Filament', files: 'Gestionnaire de fichiers', + notifications: 'Notifications', settings: 'Paramètres', system: 'Système', collapseSidebar: 'Réduire la barre latérale', @@ -1286,6 +1287,9 @@ export default { notificationLanguageDescription: 'Langue pour les notifications push', bedCooledThreshold: 'Seuil de refroidissement du plateau', bedCooledThresholdDescription: 'Température en dessous de laquelle le plateau est considéré comme refroidi', + userNotificationsEnabled: 'Notifications utilisateur', + userNotificationsEnabledDescription: "Active le menu de notifications utilisateur et les notifications par e-mail pour les événements d'impression. Nécessite l'authentification avancée.", + userNotificationsDisabledHint: "Activez l'authentification avancée pour utiliser les notifications utilisateur.", notificationProviders: 'Fournisseurs de notifications', addProvider: 'Ajouter un fournisseur', editProvider: 'Modifier le fournisseur', @@ -3919,6 +3923,26 @@ export default { maintenance_due: 'Maintenance requise', test: 'Test', }, + // User email notification preferences + userEmail: { + title: 'Notifications', + emailNotifications: 'Notifications par e-mail', + emailNotificationsDesc: "Recevez des notifications par e-mail pour vos propres travaux d'impression. Les e-mails sont envoyés via les paramètres SMTP configurés dans l'authentification avancée.", + sendingTo: 'Les notifications seront envoyées à', + noEmailWarning: "Votre compte n'a pas d'adresse e-mail. Contactez un administrateur pour en ajouter une.", + printJobNotifications: "Notifications de travaux d'impression", + printJobNotificationsDesc: "Choisissez quels événements déclenchent des notifications par e-mail pour les travaux d'impression que vous soumettez.", + printJobStarts: "Démarrage du travail d'impression", + printJobStartsDesc: "Être notifié quand votre travail d'impression commence.", + printJobFinishes: "Fin du travail d'impression", + printJobFinishesDesc: "Être notifié quand votre travail d'impression se termine avec succès.", + printErrors: "Erreurs d'impression", + printErrorsDesc: "Être notifié quand votre travail d'impression échoue ou rencontre une erreur.", + printJobStops: "Travail d'impression arrêté", + printJobStopsDesc: "Être notifié quand votre travail d'impression est annulé ou arrêté.", + saveSuccess: 'Préférences de notification sauvegardées.', + saveError: 'Impossible de sauvegarder les préférences de notification.', + }, }, // Rich Text Editor diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 3305913b9..8ee02232b 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -10,6 +10,7 @@ export default { projects: 'Progetti', inventory: 'Filamento', files: 'File', + notifications: 'Notifiche', settings: 'Impostazioni', system: 'Sistema', collapseSidebar: 'Comprimi barra laterale', @@ -1286,6 +1287,9 @@ export default { notificationLanguageDescription: 'Lingua per notifiche push', bedCooledThreshold: 'Soglia raffreddamento piatto', bedCooledThresholdDescription: 'Temperatura sotto la quale il piatto è considerato raffreddato dopo una stampa', + userNotificationsEnabled: 'Notifiche utente', + userNotificationsEnabledDescription: "Abilita il menu notifiche utente e le notifiche e-mail per gli eventi di stampa. Richiede l'autenticazione avanzata.", + userNotificationsDisabledHint: "Abilita l'autenticazione avanzata per usare le notifiche utente.", notificationProviders: 'Provider notifiche', addProvider: 'Aggiungi provider', editProvider: 'Modifica provider', @@ -3918,6 +3922,26 @@ export default { maintenance_due: 'Manutenzione necessaria', test: 'Prova', }, + // User email notification preferences + userEmail: { + title: 'Notifiche', + emailNotifications: 'Notifiche via e-mail', + emailNotificationsDesc: "Ricevi notifiche via e-mail per i tuoi lavori di stampa. Le e-mail vengono inviate tramite le impostazioni SMTP configurate nell'autenticazione avanzata.", + sendingTo: 'Le notifiche verranno inviate a', + noEmailWarning: "Il tuo account non ha un indirizzo e-mail. Contatta un amministratore per aggiungerne uno.", + printJobNotifications: 'Notifiche lavori di stampa', + printJobNotificationsDesc: 'Scegli quali eventi attivano le notifiche e-mail per i lavori di stampa che invii.', + printJobStarts: 'Inizio lavoro di stampa', + printJobStartsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa inizia.', + printJobFinishes: 'Fine lavoro di stampa', + printJobFinishesDesc: 'Ricevi una notifica quando il tuo lavoro di stampa si completa correttamente.', + printErrors: 'Errori di stampa', + printErrorsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa fallisce o incontra un errore.', + printJobStops: 'Lavoro di stampa interrotto', + printJobStopsDesc: 'Ricevi una notifica quando il tuo lavoro di stampa viene annullato o interrotto.', + saveSuccess: 'Preferenze di notifica salvate.', + saveError: 'Impossibile salvare le preferenze di notifica.', + }, }, // Rich Text Editor diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index ee0dc2bbb..4fc09b13d 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -10,6 +10,7 @@ export default { projects: 'プロジェクト', inventory: 'フィラメント', files: 'ファイル管理', + notifications: '通知', settings: '設定', system: 'システム', collapseSidebar: 'サイドバーを閉じる', @@ -1285,6 +1286,9 @@ export default { notificationLanguageDescription: 'プッシュ通知の言語', bedCooledThreshold: 'ベッド冷却しきい値', bedCooledThresholdDescription: '印刷後にベッドが冷却されたと見なす温度', + userNotificationsEnabled: 'ユーザー通知', + userNotificationsEnabledDescription: 'ユーザー通知メニューと印刷ジョブイベントのメール通知を有効にします。高度な認証が必要です。', + userNotificationsDisabledHint: 'ユーザー通知を使用するには高度な認証を有効にしてください。', notificationProviders: '通知プロバイダー', addProvider: 'プロバイダーを追加', editProvider: 'プロバイダーを編集', @@ -3931,6 +3935,26 @@ export default { maintenance_due: 'メンテナンス期限', test: 'テスト', }, + // User email notification preferences + userEmail: { + title: '通知', + emailNotifications: 'メール通知', + emailNotificationsDesc: '自分の印刷ジョブに対してメール通知を受け取ります。メールは高度な認証で設定されたSMTP設定を使用して送信されます。', + sendingTo: '通知の送信先', + noEmailWarning: 'アカウントにメールアドレスが設定されていません。管理者に連絡して追加してもらってください。', + printJobNotifications: '印刷ジョブ通知', + printJobNotificationsDesc: '送信した印刷ジョブのどのイベントでメール通知を送るかを選択します。', + printJobStarts: '印刷ジョブ開始', + printJobStartsDesc: '印刷ジョブが開始されたときに通知を受け取る。', + printJobFinishes: '印刷ジョブ完了', + printJobFinishesDesc: '印刷ジョブが正常に完了したときに通知を受け取る。', + printErrors: '印刷エラー', + printErrorsDesc: '印刷ジョブが失敗またはエラーが発生したときに通知を受け取る。', + printJobStops: '印刷ジョブ停止', + printJobStopsDesc: '印刷ジョブがキャンセルまたは停止されたときに通知を受け取る。', + saveSuccess: '通知設定を保存しました。', + saveError: '通知設定の保存に失敗しました。', + }, }, // Rich Text Editor diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index cbd903f9a..bdaa8df0d 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -10,6 +10,7 @@ export default { projects: 'Projetos', inventory: 'Inventário', files: 'Gerenciador de Arquivos', + notifications: 'Notificações', settings: 'Configurações', system: 'Sistema', collapseSidebar: 'Recolher barra lateral', @@ -1286,6 +1287,9 @@ export default { notificationLanguageDescription: 'Idioma para notificações push', bedCooledThreshold: 'Limite de Resfriamento da Cama', bedCooledThresholdDescription: 'Temperatura abaixo da qual a cama é considerada resfriada após uma impressão', + userNotificationsEnabled: 'Notificações do Usuário', + userNotificationsEnabledDescription: 'Ativa o menu de notificações do usuário e notificações por e-mail para eventos de impressão. Requer Autenticação Avançada.', + userNotificationsDisabledHint: 'Ative a Autenticação Avançada para usar as notificações do usuário.', notificationProviders: 'Provedores de Notificação', addProvider: 'Adicionar Provedor', editProvider: 'Editar Provedor', @@ -3918,6 +3922,26 @@ export default { maintenance_due: 'Manutenção Necessária', test: 'Teste', }, + // User email notification preferences + userEmail: { + title: 'Notificações', + emailNotifications: 'Notificações por E-mail', + emailNotificationsDesc: 'Receba notificações por e-mail para seus próprios trabalhos de impressão. Os e-mails são enviados usando as configurações SMTP definidas na Autenticação Avançada.', + sendingTo: 'As notificações serão enviadas para', + noEmailWarning: 'Sua conta não tem um endereço de e-mail. Entre em contato com um administrador para adicionar um.', + printJobNotifications: 'Notificações de Trabalhos de Impressão', + printJobNotificationsDesc: 'Escolha quais eventos acionam notificações por e-mail para os trabalhos de impressão que você envia.', + printJobStarts: 'Início do Trabalho de Impressão', + printJobStartsDesc: 'Ser notificado quando seu trabalho de impressão começar.', + printJobFinishes: 'Conclusão do Trabalho de Impressão', + printJobFinishesDesc: 'Ser notificado quando seu trabalho de impressão concluir com sucesso.', + printErrors: 'Erros de Impressão', + printErrorsDesc: 'Ser notificado quando seu trabalho de impressão falhar ou encontrar um erro.', + printJobStops: 'Trabalho de Impressão Parado', + printJobStopsDesc: 'Ser notificado quando seu trabalho de impressão for cancelado ou parado.', + saveSuccess: 'Preferências de notificação salvas.', + saveError: 'Falha ao salvar preferências de notificação.', + }, }, // Rich Text Editor diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 505cdf8f8..f64d2a4ff 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -10,6 +10,7 @@ export default { projects: '项目', inventory: '耗材', files: '文件管理器', + notifications: '通知', settings: '设置', system: '系统', collapseSidebar: '收起侧边栏', @@ -1286,6 +1287,9 @@ export default { notificationLanguageDescription: '推送通知的语言', bedCooledThreshold: '热床冷却阈值', bedCooledThresholdDescription: '打印后热床被视为已冷却的温度', + userNotificationsEnabled: '用户通知', + userNotificationsEnabledDescription: '启用用户通知菜单和打印任务事件的邮件通知。需要高级身份验证。', + userNotificationsDisabledHint: '请启用高级身份验证以使用用户通知。', notificationProviders: '通知提供商', addProvider: '添加提供商', editProvider: '编辑提供商', @@ -3918,6 +3922,25 @@ export default { maintenance_due: '需要维护', test: '测试', }, + userEmail: { + title: '通知', + emailNotifications: '邮件通知', + emailNotificationsDesc: '接收您自己打印任务的邮件通知。邮件将通过高级身份验证中配置的 SMTP 设置发送。', + sendingTo: '通知将发送至', + noEmailWarning: '您的账户没有邮件地址。请联系管理员添加。', + printJobNotifications: '打印任务通知', + printJobNotificationsDesc: '选择哪些事件会触发您提交的打印任务的邮件通知。', + printJobStarts: '打印任务开始', + printJobStartsDesc: '当您的打印任务开始时收到通知。', + printJobFinishes: '打印任务完成', + printJobFinishesDesc: '当您的打印任务成功完成时收到通知。', + printErrors: '打印错误', + printErrorsDesc: '当您的打印任务失败或遇到错误时收到通知。', + printJobStops: '打印任务停止', + printJobStopsDesc: '当您的打印任务被取消或停止时收到通知。', + saveSuccess: '通知偏好设置已保存。', + saveError: '保存通知偏好设置失败。', + }, }, // Rich Text Editor diff --git a/frontend/src/pages/NotificationsPage.tsx b/frontend/src/pages/NotificationsPage.tsx new file mode 100644 index 000000000..15543325b --- /dev/null +++ b/frontend/src/pages/NotificationsPage.tsx @@ -0,0 +1,268 @@ +import { useState, useEffect } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Bell, CheckCircle2, Loader2, Mail, Save } from 'lucide-react'; +import { api } from '../api/client'; +import { useAuth } from '../contexts/AuthContext'; +import { useToast } from '../contexts/ToastContext'; +import { Button } from '../components/Button'; +import { Card, CardContent, CardHeader } from '../components/Card'; + +export function NotificationsPage() { + const { t } = useTranslation(); + const { user } = useAuth(); + const { showToast } = useToast(); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const [notifyPrintStart, setNotifyPrintStart] = useState(true); + const [notifyPrintComplete, setNotifyPrintComplete] = useState(true); + const [notifyPrintFailed, setNotifyPrintFailed] = useState(true); + const [notifyPrintStopped, setNotifyPrintStopped] = useState(true); + const [isDirty, setIsDirty] = useState(false); + + // Check advanced auth status - redirect if disabled + const { data: advancedAuthStatus, isLoading: isAdvancedAuthLoading } = useQuery({ + queryKey: ['advancedAuthStatus'], + queryFn: api.getAdvancedAuthStatus, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + const { data: settings, isLoading: isSettingsLoading } = useQuery({ + queryKey: ['settings'], + queryFn: api.getSettings, + staleTime: 5 * 60 * 1000, + }); + + // Fetch current preferences + const { data: preferences, isLoading } = useQuery({ + queryKey: ['user-email-preferences'], + queryFn: () => api.getUserEmailPreferences(), + }); + + // Redirect to settings if Advanced Auth is disabled + useEffect(() => { + if ((advancedAuthStatus && !advancedAuthStatus.advanced_auth_enabled) || (settings && !settings.user_notifications_enabled)) { + navigate('/settings', { replace: true }); + } + }, [advancedAuthStatus, settings, navigate]); + + // Populate form when preferences load + useEffect(() => { + if (preferences) { + setNotifyPrintStart(preferences.notify_print_start); + setNotifyPrintComplete(preferences.notify_print_complete); + setNotifyPrintFailed(preferences.notify_print_failed); + setNotifyPrintStopped(preferences.notify_print_stopped); + setIsDirty(false); + } + }, [preferences]); + + // Save preferences + const saveMutation = useMutation({ + mutationFn: () => + api.updateUserEmailPreferences({ + notify_print_start: notifyPrintStart, + notify_print_complete: notifyPrintComplete, + notify_print_failed: notifyPrintFailed, + notify_print_stopped: notifyPrintStopped, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['user-email-preferences'] }); + setIsDirty(false); + showToast(t('notifications.userEmail.saveSuccess'), 'success'); + }, + onError: (err: Error) => { + showToast(err.message || t('notifications.userEmail.saveError'), 'error'); + }, + }); + + const handleToggle = ( + setter: React.Dispatch>, + value: boolean + ) => { + setter(!value); + setIsDirty(true); + }; + + if (isLoading || isAdvancedAuthLoading || isSettingsLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+ +

{t('notifications.userEmail.title')}

+
+ + {/* Info card */} + + +
+
+ +
+
+

{t('notifications.userEmail.emailNotifications')}

+

+ {t('notifications.userEmail.emailNotificationsDesc')} +

+ {user?.email ? ( +

+ {t('notifications.userEmail.sendingTo')}: {user.email} +

+ ) : ( +

+ {t('notifications.userEmail.noEmailWarning')} +

+ )} +
+
+
+
+ + {/* Preferences card */} + + +

{t('notifications.userEmail.printJobNotifications')}

+

{t('notifications.userEmail.printJobNotificationsDesc')}

+
+ + {/* Print Job Starts */} +
+
+
+ +
+
+

{t('notifications.userEmail.printJobStarts')}

+

{t('notifications.userEmail.printJobStartsDesc')}

+
+
+ +
+ + {/* Print Job Finishes */} +
+
+
+ +
+
+

{t('notifications.userEmail.printJobFinishes')}

+

{t('notifications.userEmail.printJobFinishesDesc')}

+
+
+ +
+ + {/* Print Errors */} +
+
+
+ +
+
+

{t('notifications.userEmail.printErrors')}

+

{t('notifications.userEmail.printErrorsDesc')}

+
+
+ +
+ + {/* Print Job Stops */} +
+
+
+ +
+
+

{t('notifications.userEmail.printJobStops')}

+

{t('notifications.userEmail.printJobStopsDesc')}

+
+
+ +
+
+
+ + {/* Save button */} +
+ +
+
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index d4c279a2b..2a52494f9 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -523,6 +523,7 @@ export function SettingsPage() { const updateData: UserUpdate = { username: userFormData.username || undefined, password: userFormData.password || undefined, + email: userFormData.email || undefined, role: userFormData.role, group_ids: userFormData.group_ids, }; @@ -738,7 +739,8 @@ export function SettingsPage() { (settings.camera_view_mode ?? 'window') !== (localSettings.camera_view_mode ?? 'window') || (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') || settings.prometheus_enabled !== localSettings.prometheus_enabled || - settings.prometheus_token !== localSettings.prometheus_token; + settings.prometheus_token !== localSettings.prometheus_token || + (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true); if (!hasChanges) { return; @@ -810,6 +812,7 @@ export function SettingsPage() { preferred_slicer: localSettings.preferred_slicer, prometheus_enabled: localSettings.prometheus_enabled, prometheus_token: localSettings.prometheus_token, + user_notifications_enabled: localSettings.user_notifications_enabled, }; updateMutation.mutate(settingsToSave); }, 500); @@ -2784,6 +2787,32 @@ export function SettingsPage() { + {/* User Notifications Toggle */} + + +
+
+

{t('settings.userNotificationsEnabled')}

+

+ {!advancedAuthStatus?.advanced_auth_enabled + ? t('settings.userNotificationsDisabledHint') + : t('settings.userNotificationsEnabledDescription')} +

+
+ +
+
+
+ {/* Test All Results */} {testAllResult && (