Feature: Advanced Authentication User Email Notifications (#693)

Feature: Advanced Authentication User Email Notifications (#693)
This commit is contained in:
Thomas Rambach
2026-03-17 12:01:18 +01:00
committed by GitHub
parent 5a8dd8cd69
commit 9562d66b6b
29 changed files with 1328 additions and 13 deletions
@@ -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",
}
+11
View File
@@ -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)
+1
View File
@@ -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",
@@ -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
+2 -1
View File
@@ -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)
+47
View File
@@ -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."""
+3 -1
View File
@@ -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
+327 -5
View File
@@ -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)
@@ -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}",
},
]
+10
View File
@@ -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.
+31
View File
@@ -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")
@@ -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",
},
}
+7
View File
@@ -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
+24
View File
@@ -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
+66
View File
@@ -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", "<br>\n")
html_body = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #1db954 0%, #158a3e 100%); background-color: #1db954; padding: 20px; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
</div>
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
<div style="font-size: 16px;">{escaped_text_body}</div>
</div>
</body>
</html>
"""
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)
@@ -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(
+8 -1
View File
@@ -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
+2
View File
@@ -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() {
<Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
<Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
<Route path="system" element={<SystemInfoPage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="external/:id" element={<ExternalLinkPage />} />
</Route>
</Routes>
+20 -1
View File
@@ -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<AppSettings>;
@@ -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<UserEmailPreferences>('/user-notifications/preferences'),
updateUserEmailPreferences: (data: UserEmailPreferences) =>
request<UserEmailPreferences>('/user-notifications/preferences', {
method: 'PUT',
body: JSON.stringify(data),
}),
// Groups
getPermissions: () => request<PermissionsListResponse>('/groups/permissions'),
getGroups: () => request<Group[]>('/groups/'),
+19 -3
View File
@@ -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) {
+24
View File
@@ -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
+24
View File
@@ -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
+24
View File
@@ -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
+24
View File
@@ -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
+24
View File
@@ -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
+24
View File
@@ -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
+23
View File
@@ -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
+268
View File
@@ -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<React.SetStateAction<boolean>>,
value: boolean
) => {
setter(!value);
setIsDirty(true);
};
if (isLoading || isAdvancedAuthLoading || isSettingsLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
</div>
);
}
return (
<div className="p-4 md:p-6 max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Bell className="w-7 h-7 text-bambu-green" />
<h1 className="text-2xl font-bold text-white">{t('notifications.userEmail.title')}</h1>
</div>
{/* Info card */}
<Card className="mb-6 border-blue-500/30 bg-blue-500/5">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-blue-500/20 flex-shrink-0">
<Mail className="w-5 h-5 text-blue-400" />
</div>
<div>
<h3 className="text-white font-medium">{t('notifications.userEmail.emailNotifications')}</h3>
<p className="text-sm text-bambu-gray mt-1">
{t('notifications.userEmail.emailNotificationsDesc')}
</p>
{user?.email ? (
<p className="text-sm text-blue-400 mt-2">
{t('notifications.userEmail.sendingTo')}: <strong>{user.email}</strong>
</p>
) : (
<p className="text-sm text-yellow-400 mt-2">
{t('notifications.userEmail.noEmailWarning')}
</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Preferences card */}
<Card className="mb-6">
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('notifications.userEmail.printJobNotifications')}</h2>
<p className="text-sm text-bambu-gray mt-1">{t('notifications.userEmail.printJobNotificationsDesc')}</p>
</CardHeader>
<CardContent className="space-y-4">
{/* Print Job Starts */}
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintStart ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
<CheckCircle2 className={`w-5 h-5 ${notifyPrintStart ? 'text-bambu-green' : 'text-bambu-gray'}`} />
</div>
<div>
<p className="text-white font-medium">{t('notifications.userEmail.printJobStarts')}</p>
<p className="text-sm text-bambu-gray">{t('notifications.userEmail.printJobStartsDesc')}</p>
</div>
</div>
<button
onClick={() => handleToggle(setNotifyPrintStart, notifyPrintStart)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
notifyPrintStart ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
role="switch"
aria-checked={notifyPrintStart}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
notifyPrintStart ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Print Job Finishes */}
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintComplete ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
<CheckCircle2 className={`w-5 h-5 ${notifyPrintComplete ? 'text-bambu-green' : 'text-bambu-gray'}`} />
</div>
<div>
<p className="text-white font-medium">{t('notifications.userEmail.printJobFinishes')}</p>
<p className="text-sm text-bambu-gray">{t('notifications.userEmail.printJobFinishesDesc')}</p>
</div>
</div>
<button
onClick={() => handleToggle(setNotifyPrintComplete, notifyPrintComplete)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
notifyPrintComplete ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
role="switch"
aria-checked={notifyPrintComplete}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
notifyPrintComplete ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Print Errors */}
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintFailed ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
<CheckCircle2 className={`w-5 h-5 ${notifyPrintFailed ? 'text-bambu-green' : 'text-bambu-gray'}`} />
</div>
<div>
<p className="text-white font-medium">{t('notifications.userEmail.printErrors')}</p>
<p className="text-sm text-bambu-gray">{t('notifications.userEmail.printErrorsDesc')}</p>
</div>
</div>
<button
onClick={() => handleToggle(setNotifyPrintFailed, notifyPrintFailed)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
notifyPrintFailed ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
role="switch"
aria-checked={notifyPrintFailed}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
notifyPrintFailed ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Print Job Stops */}
<div className="flex items-center justify-between p-4 bg-bambu-dark rounded-lg">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${notifyPrintStopped ? 'bg-bambu-green/20' : 'bg-bambu-dark-tertiary'}`}>
<CheckCircle2 className={`w-5 h-5 ${notifyPrintStopped ? 'text-bambu-green' : 'text-bambu-gray'}`} />
</div>
<div>
<p className="text-white font-medium">{t('notifications.userEmail.printJobStops')}</p>
<p className="text-sm text-bambu-gray">{t('notifications.userEmail.printJobStopsDesc')}</p>
</div>
</div>
<button
onClick={() => handleToggle(setNotifyPrintStopped, notifyPrintStopped)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
notifyPrintStopped ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
}`}
role="switch"
aria-checked={notifyPrintStopped}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
notifyPrintStopped ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</CardContent>
</Card>
{/* Save button */}
<div className="flex justify-end">
<Button
onClick={() => saveMutation.mutate()}
disabled={!isDirty || saveMutation.isPending || !user?.email}
>
{saveMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.saving')}
</>
) : (
<>
<Save className="w-4 h-4" />
{t('common.save')}
</>
)}
</Button>
</div>
</div>
);
}
+30 -1
View File
@@ -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() {
</CardContent>
</Card>
{/* User Notifications Toggle */}
<Card className="mb-4">
<CardContent className="py-3">
<div className={`flex items-center justify-between ${!advancedAuthStatus?.advanced_auth_enabled ? 'opacity-50' : ''}`}>
<div>
<p className="text-white text-sm font-medium">{t('settings.userNotificationsEnabled')}</p>
<p className="text-xs text-bambu-gray">
{!advancedAuthStatus?.advanced_auth_enabled
? t('settings.userNotificationsDisabledHint')
: t('settings.userNotificationsEnabledDescription')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={localSettings.user_notifications_enabled ?? true}
disabled={!advancedAuthStatus?.advanced_auth_enabled}
onChange={(e) => updateSetting('user_notifications_enabled', e.target.checked)}
/>
<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 peer-disabled:cursor-not-allowed"></div>
</label>
</div>
</CardContent>
</Card>
{/* Test All Results */}
{testAllResult && (
<Card className="mb-4">