diff --git a/.basedpyright/baseline.json b/.basedpyright/baseline.json
index 389f7c765..1196bceff 100644
--- a/.basedpyright/baseline.json
+++ b/.basedpyright/baseline.json
@@ -3363,14 +3363,6 @@
"lineCount": 1
}
},
- {
- "code": "reportAssignmentType",
- "range": {
- "startColumn": 32,
- "endColumn": 50,
- "lineCount": 1
- }
- },
{
"code": "reportAssignmentType",
"range": {
@@ -4261,38 +4253,6 @@
}
],
"./backend/app/services/email_service.py": [
- {
- "code": "reportAttributeAccessIssue",
- "range": {
- "startColumn": 43,
- "endColumn": 50,
- "lineCount": 1
- }
- },
- {
- "code": "reportAttributeAccessIssue",
- "range": {
- "startColumn": 45,
- "endColumn": 49,
- "lineCount": 1
- }
- },
- {
- "code": "reportAttributeAccessIssue",
- "range": {
- "startColumn": 45,
- "endColumn": 54,
- "lineCount": 1
- }
- },
- {
- "code": "reportAttributeAccessIssue",
- "range": {
- "startColumn": 85,
- "endColumn": 94,
- "lineCount": 1
- }
- },
{
"code": "reportArgumentType",
"range": {
@@ -6809,14 +6769,6 @@
}
],
"./backend/app/services/virtual_printer/mqtt_server.py": [
- {
- "code": "reportMissingImports",
- "range": {
- "startColumn": 17,
- "endColumn": 29,
- "lineCount": 1
- }
- },
{
"code": "reportGeneralTypeIssues",
"range": {
@@ -6827,14 +6779,6 @@
}
],
"./backend/app/services/virtual_printer/tcp_proxy.py": [
- {
- "code": "reportAttributeAccessIssue",
- "range": {
- "startColumn": 25,
- "endColumn": 33,
- "lineCount": 1
- }
- },
{
"code": "reportArgumentType",
"range": {
@@ -6899,30 +6843,6 @@
"lineCount": 1
}
},
- {
- "code": "reportArgumentType",
- "range": {
- "startColumn": 97,
- "endColumn": 108,
- "lineCount": 1
- }
- },
- {
- "code": "reportArgumentType",
- "range": {
- "startColumn": 93,
- "endColumn": 104,
- "lineCount": 1
- }
- },
- {
- "code": "reportArgumentType",
- "range": {
- "startColumn": 49,
- "endColumn": 73,
- "lineCount": 1
- }
- },
{
"code": "reportRedeclaration",
"range": {
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 98a30399a..b28df25b1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -270,6 +270,10 @@ jobs:
working-directory: frontend
run: npm run lint
+ - name: Unused files and dependencies (knip)
+ working-directory: frontend
+ run: npm run check:knip
+
frontend-security:
name: Frontend Security
runs-on: ubuntu-latest
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 41135885b..ee8793f4c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -225,7 +225,8 @@ Then regenerate the type-check lock (command in `requirements-typecheck.txt`), i
### Frontend (TypeScript/React)
-We use ESLint for linting and TypeScript for type checking:
+We use ESLint for linting, TypeScript for type checking and knip for unused
+files and dependencies:
```bash
cd frontend
@@ -235,8 +236,17 @@ npm run lint
# Type check
npm run typecheck
+
+# Unused files and dependencies
+npm run check:knip
```
+knip follows imports from the entry points (`index.html`, the Vite and Vitest
+configs, and `public/sw.js`, which the browser loads by URL). A file nothing
+imports, or a package nothing uses, fails the check: delete it, or add it to
+`frontend/knip.jsonc` with a comment saying why if something loads it in a way
+knip can't see.
+
### Pre-commit Hooks
Pre-commit hooks run automatically on `git commit` and include Ruff linting/formatting, trailing whitespace fixes, YAML/JSON validation, and import shadowing checks. To run manually:
@@ -347,7 +357,7 @@ reprint eligibility. Archive reprint still needs `queue:create` before it can en
The easiest way to run tests is with the provided scripts in the project root:
```bash
-./test_frontend.sh # TypeScript check + ESLint + Vitest
+./test_frontend.sh # TypeScript check + ESLint + knip + Vitest
./test_backend.sh # Ruff lint/format + type check (if .typecheck-venv exists) + PostgreSQL upgrade check (if Docker is available) + pytest (parallel)
./test_docker.sh # Full Docker build, unit tests, and integration tests
./test_all.sh # All of the above (frontend → backend → docker)
@@ -415,7 +425,7 @@ npm run test:coverage # With coverage
Pull requests trigger automated CI checks via GitHub Actions (`.github/workflows/ci.yml`):
- **Backend**: Ruff lint + format check, basedpyright type check (against the baseline), unit/integration tests, PostgreSQL upgrade check, pip-audit
-- **Frontend**: ESLint, TypeScript type check, Vitest tests, production build
+- **Frontend**: ESLint, knip (unused files and dependencies), TypeScript type check, Vitest tests, production build
- **Docker**: Full image build, backend/frontend tests in Docker, integration health checks
- **Security**: CodeQL analysis, dependency audits
diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py
index 71577f3a7..1257109d1 100644
--- a/backend/app/api/routes/settings.py
+++ b/backend/app/api/routes/settings.py
@@ -1057,7 +1057,7 @@ def _read_backup_manifest(temp_path: Path) -> dict:
return data if isinstance(data, dict) else {}
-async def _import_sqlite_to_postgres(sqlite_path: Path, postgres_url: str):
+async def _import_sqlite_to_postgres(sqlite_path: Path):
"""Import data from a SQLite database file into the current PostgreSQL database.
Used for cross-database restore (SQLite backup → PostgreSQL).
@@ -1534,7 +1534,7 @@ async def restore_backup(
else:
# Import SQLite backup into PostgreSQL
logger.info("Importing SQLite backup into PostgreSQL...")
- await _import_sqlite_to_postgres(backup_db, app_settings.database_url)
+ await _import_sqlite_to_postgres(backup_db)
# 6. Replace data directories
# For Docker compatibility: clear contents then copy (don't delete mount points)
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
index 8609cbc88..6168964d5 100644
--- a/backend/app/core/auth.py
+++ b/backend/app/core/auth.py
@@ -441,7 +441,7 @@ def _check_apikey_permissions(
``resolve_apikey_owner``.
By default ALL requested permissions must pass (mirrors
- ``require_permission`` / ``require_permission_if_auth_enabled``).
+ ``require_permission_if_auth_enabled``).
When ``require_any=True``, only one needs to pass (mirrors
``require_any_permission_if_auth_enabled``).
"""
@@ -1658,20 +1658,6 @@ async def require_auth_if_enabled(
)
-def require_role(required_role: str):
- """Dependency factory for role-based access control."""
-
- async def role_checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
- if current_user.role != required_role:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=f"Requires {required_role} role",
- )
- return current_user
-
- return role_checker
-
-
def require_admin_if_auth_enabled():
"""Dependency factory that requires admin role if auth is enabled.
@@ -2160,9 +2146,6 @@ def require_printer_permission_if_auth_enabled(*permissions: str | Permission):
# Convenience dependencies - these are functions that return Depends objects
-def RequireAdmin():
- """Dependency that requires admin role."""
- return Depends(require_role("admin"))
def RequireAdminIfAuthEnabled():
@@ -2170,84 +2153,6 @@ def RequireAdminIfAuthEnabled():
return Depends(require_admin_if_auth_enabled())
-def require_permission(*permissions: str | Permission):
- """Dependency factory that requires user to have ALL specified permissions.
-
- Accepts both JWT tokens (via Authorization: Bearer header) and API keys
- (via X-API-Key header or Authorization: Bearer bb_xxx).
-
- Args:
- *permissions: Permission strings or Permission enum values to require
-
- Returns:
- A dependency function that validates permissions
- """
- # Convert Permission enums to strings
- perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
-
- async def permission_checker(
- credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
- x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
- ) -> User | None:
- async with async_session() as db:
- # Check for API key first (X-API-Key header)
- if x_api_key:
- api_key = await _validate_api_key(db, x_api_key)
- if api_key:
- await authorize_api_key(db, api_key, perm_strings)
- return None # API key valid, allow access
-
- credentials_exception = HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Could not validate credentials",
- headers={"WWW-Authenticate": "Bearer"},
- )
-
- if credentials is None:
- raise credentials_exception
-
- token = credentials.credentials
- # Check if it's an API key (starts with bb_)
- if token.startswith("bb_"):
- api_key = await _validate_api_key(db, token)
- if api_key:
- await authorize_api_key(db, api_key, perm_strings)
- return None # API key valid, allow access
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Invalid API key",
- headers={"WWW-Authenticate": "Bearer"},
- )
-
- # Otherwise treat as JWT
- try:
- payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
- username: str = payload.get("sub")
- if username is None:
- raise credentials_exception
- jti: str | None = payload.get("jti")
- if not jti or await is_jti_revoked(jti, db):
- raise credentials_exception
- iat: int | float | None = payload.get("iat")
- except JWTError:
- raise credentials_exception
-
- user = await get_user_by_username(db, username)
- if user is None or not user.is_active:
- raise credentials_exception
- if not _is_token_fresh(iat, user):
- raise credentials_exception
-
- if not user.has_all_permissions(*perm_strings):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=f"Missing required permissions: {', '.join(perm_strings)}",
- )
- return user
-
- return permission_checker
-
-
def require_permission_if_auth_enabled(*permissions: str | Permission):
"""Dependency factory that checks permissions only if auth is enabled.
@@ -2359,11 +2264,6 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
return permission_checker
-def RequirePermission(*permissions: str | Permission):
- """Convenience dependency that requires ALL specified permissions."""
- return Depends(require_permission(*permissions))
-
-
def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
"""Convenience dependency that requires permissions if auth is enabled."""
return Depends(require_permission_if_auth_enabled(*permissions))
diff --git a/backend/app/core/printer_scope.py b/backend/app/core/printer_scope.py
index deb9cf8f7..b9c82d459 100644
--- a/backend/app/core/printer_scope.py
+++ b/backend/app/core/printer_scope.py
@@ -189,11 +189,3 @@ def api_key_own_scope(api_key) -> PrinterScope:
if api_key.printer_ids is None:
return ALL_PRINTERS
return PrinterScope(frozenset(int(pid) for pid in api_key.printer_ids))
-
-
-async def group_restricts_printers(db: AsyncSession, group_ids: Iterable[int]) -> bool:
- ids = list(group_ids)
- if not ids:
- return False
- result = await db.execute(select(Group.id).where(Group.id.in_(ids), Group.restrict_printers.is_(True)).limit(1))
- return result.first() is not None
diff --git a/backend/app/i18n/__init__.py b/backend/app/i18n/__init__.py
deleted file mode 100644
index c24f9215e..000000000
--- a/backend/app/i18n/__init__.py
+++ /dev/null
@@ -1,125 +0,0 @@
-"""Internationalization module for backend notifications."""
-
-from typing import Any
-
-# English translations
-EN = {
- "notification": {
- # Print events
- "print_started": "Print Started",
- "print_completed": "Print Completed",
- "print_failed": "Print Failed",
- "print_stopped": "Print Stopped",
- "print_ended": "Print Ended",
- "print_progress": "Print {progress}% Complete",
- "estimated": "Estimated",
- "time": "Time",
- "filament": "Filament",
- "reason": "Reason",
- "unknown": "Unknown",
- # Printer events
- "printer_offline": "Printer Offline",
- "printer_disconnected": "{printer} has disconnected",
- "printer_error": "Printer Error: {error_type}",
- # Filament
- "filament_low": "Filament Low",
- "slot_at_percent": "{printer}: Slot {slot} at {percent}%",
- # Maintenance
- "maintenance_due": "Maintenance Due",
- "overdue": "OVERDUE",
- "soon": "Soon",
- # Test notification
- "test_title": "Bambuddy Test",
- "test_message": "This is a test notification from Bambuddy. If you see this, notifications are working correctly!",
- }
-}
-
-# German translations
-DE = {
- "notification": {
- # Print events
- "print_started": "Druck gestartet",
- "print_completed": "Druck abgeschlossen",
- "print_failed": "Druck fehlgeschlagen",
- "print_stopped": "Druck gestoppt",
- "print_ended": "Druck beendet",
- "print_progress": "Druck {progress}% fertig",
- "estimated": "Geschätzt",
- "time": "Zeit",
- "filament": "Filament",
- "reason": "Grund",
- "unknown": "Unbekannt",
- # Printer events
- "printer_offline": "Drucker offline",
- "printer_disconnected": "{printer} wurde getrennt",
- "printer_error": "Druckerfehler: {error_type}",
- # Filament
- "filament_low": "Wenig Filament",
- "slot_at_percent": "{printer}: Slot {slot} bei {percent}%",
- # Maintenance
- "maintenance_due": "Wartung fällig",
- "overdue": "ÜBERFÄLLIG",
- "soon": "Bald",
- # Test notification
- "test_title": "Bambuddy Test",
- "test_message": "Dies ist eine Testbenachrichtigung von Bambuddy. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!",
- }
-}
-
-# All available translations
-TRANSLATIONS = {
- "en": EN,
- "de": DE,
-}
-
-
-def get_translation(lang: str, key: str, **kwargs: Any) -> str:
- """
- Get a translation string by key with optional interpolation.
-
- Args:
- lang: Language code (e.g., 'en', 'de')
- key: Dot-separated key path (e.g., 'notification.print_started')
- **kwargs: Values to interpolate into the string
-
- Returns:
- Translated string, or the key if not found
- """
- # Fall back to English if language not found
- translations = TRANSLATIONS.get(lang, TRANSLATIONS["en"])
-
- # Navigate to the nested key
- keys = key.split(".")
- value = translations
- for k in keys:
- if isinstance(value, dict) and k in value:
- value = value[k]
- else:
- # Key not found, fall back to English
- value = TRANSLATIONS["en"]
- for k2 in keys:
- if isinstance(value, dict) and k2 in value:
- value = value[k2]
- else:
- return key # Return key if not found in fallback either
- break
-
- if isinstance(value, str):
- # Interpolate values
- try:
- return value.format(**kwargs)
- except KeyError:
- return value
-
- return key
-
-
-class Translator:
- """Helper class for translations with a specific language."""
-
- def __init__(self, lang: str = "en"):
- self.lang = lang if lang in TRANSLATIONS else "en"
-
- def t(self, key: str, **kwargs: Any) -> str:
- """Translate a key."""
- return get_translation(self.lang, key, **kwargs)
diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py
index 61cbc5f1b..1906834e9 100644
--- a/backend/app/services/bambu_mqtt.py
+++ b/backend/app/services/bambu_mqtt.py
@@ -5937,20 +5937,6 @@ class BambuMQTTClient:
# The correct nozzle data comes from push_status response.
return True
- def _request_accessories(self):
- """Request accessories info (nozzle type, etc.) from printer."""
- if self._client:
- self._sequence_id += 1
- message = {
- "system": {
- "sequence_id": str(self._sequence_id),
- "command": "get_accessories",
- "accessory_type": "none",
- }
- }
- logger.debug("[%s] Requesting accessories info", self.serial_number)
- self._client.publish(self.topic_publish, json.dumps(message), qos=1)
-
def _prime_kprofile_request(self):
"""Send a priming K-profile request on connect.
@@ -7502,22 +7488,6 @@ class BambuMQTTClient:
speed = max(0, min(255, speed)) # Clamp to 0-255
return self.send_gcode(f"M106 P{fan} S{speed}")
- def set_part_fan(self, speed: int) -> bool:
- """Set part cooling fan speed (0-255)."""
- return self.set_fan_speed(1, speed)
-
- def set_aux_fan(self, speed: int) -> bool:
- """Set auxiliary fan speed (0-255)."""
- return self.set_fan_speed(2, speed)
-
- def set_chamber_fan(self, speed: int) -> bool:
- """Set chamber fan speed (0-255)."""
- return self.set_fan_speed(3, speed)
-
- def set_left_aux_fan(self, speed: int) -> bool:
- """Set left auxiliary part cooling fan speed (0-255). P2S/X2D accessory."""
- return self.set_fan_speed(10, speed)
-
def set_airduct_mode(self, mode: str) -> bool:
"""Set air conditioning mode (cooling or heating).
@@ -7610,56 +7580,6 @@ class BambuMQTTClient:
)
return True
- def home_axes(self, axes: str = "XYZ") -> bool:
- """Run the printer's full auto-home sequence.
-
- The ``axes`` argument is ignored: a bare ``G28`` is always sent so
- Bambu firmware runs its safe multi-step routine (park toolhead →
- home XY → home Z). Partial-axis variants like ``G28 Z`` skip the
- toolhead-park step and can crash the bed into the toolhead on H2C
- / H2D / H2S / X1 where Z-home moves the bed UP — see #1052.
- """
- return self.send_gcode("G28")
-
- def move_axis(self, axis: str, distance: float, speed: int = 3000) -> bool:
- """Move an axis by a relative distance.
-
- Args:
- axis: Axis to move ("X", "Y", or "Z")
- distance: Distance to move in mm (positive or negative)
- speed: Movement speed in mm/min
-
- Returns:
- True if command was sent, False otherwise
- """
- axis = axis.upper()
- if axis not in ("X", "Y", "Z"):
- logger.warning("[%s] Invalid axis: %s", self.serial_number, axis)
- return False
-
- # G91 = relative mode, G0 = rapid move, G90 = back to absolute
- gcode = f"G91\nG0 {axis}{distance:.2f} F{speed}\nG90"
- return self.send_gcode(gcode)
-
- def disable_motors(self) -> bool:
- """Disable all stepper motors.
-
- Warning: This will cause the printer to lose its position.
- A homing operation will be required before printing.
-
- Returns:
- True if command was sent, False otherwise
- """
- return self.send_gcode("M18")
-
- def enable_motors(self) -> bool:
- """Enable all stepper motors.
-
- Returns:
- True if command was sent, False otherwise
- """
- return self.send_gcode("M17")
-
def ams_load_filament(self, tray_id: int, extruder_id: int | None = None) -> bool:
"""Load filament from a specific AMS tray.
@@ -8315,53 +8235,6 @@ class BambuMQTTClient:
self._client.publish(self.topic_publish, command_json, qos=1)
return True
- def set_timelapse(self, enable: bool) -> bool:
- """Enable or disable timelapse recording.
-
- Args:
- enable: True to enable, False to disable
-
- Returns:
- True if command was sent, False otherwise
- """
- if not self._client or not self.state.connected:
- logger.warning("[%s] Cannot set timelapse: not connected", self.serial_number)
- return False
-
- command = {"pushing": {"command": "pushall", "sequence_id": "0"}}
- # First send the timelapse setting
- timelapse_cmd = {
- "print": {"command": "gcode_line", "param": f"M981 S{1 if enable else 0} P20000", "sequence_id": "0"}
- }
- self._client.publish(self.topic_publish, json.dumps(timelapse_cmd), qos=1)
- # Request status update
- self._client.publish(self.topic_publish, json.dumps(command), qos=1)
- logger.info("[%s] Set timelapse %s", self.serial_number, "enabled" if enable else "disabled")
- return True
-
- def set_liveview(self, enable: bool) -> bool:
- """Enable or disable live view / camera streaming.
-
- Args:
- enable: True to enable, False to disable
-
- Returns:
- True if command was sent, False otherwise
- """
- if not self._client or not self.state.connected:
- logger.warning("[%s] Cannot set liveview: not connected", self.serial_number)
- return False
-
- command = {
- "xcam": {"command": "ipcam_record_set", "control": "enable" if enable else "disable", "sequence_id": "0"}
- }
- self._client.publish(self.topic_publish, json.dumps(command), qos=1)
- # Request status update
- pushall = {"pushing": {"command": "pushall", "sequence_id": "0"}}
- self._client.publish(self.topic_publish, json.dumps(pushall), qos=1)
- logger.info("[%s] Set liveview %s", self.serial_number, "enabled" if enable else "disabled")
- return True
-
def execute_hms_action(self, print_error: str, action: str, job_id: str | None = None) -> bool:
"""Dispatch the user's choice from the HMS-error modal as a printer command.
diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py
index 2ad249373..d7d647a64 100644
--- a/backend/app/services/camera.py
+++ b/backend/app/services/camera.py
@@ -406,12 +406,6 @@ def is_chamber_image_model(model: str | None) -> bool:
return not supports_rtsp(model)
-def build_camera_url(ip_address: str, access_code: str, model: str | None) -> str:
- """Build the RTSPS URL for the printer camera (RTSP models only)."""
- port = get_camera_port(model)
- return f"rtsps://bblp:{access_code}@{ip_address}:{port}/streaming/live/1"
-
-
def _create_chamber_auth_payload(access_code: str) -> bytes:
"""Create the 80-byte authentication payload for chamber image protocol.
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
index d771aebb2..d021dc310 100644
--- a/backend/app/services/email_service.py
+++ b/backend/app/services/email_service.py
@@ -330,80 +330,6 @@ BamBuddy Team
return subject, text_body, html_body
-def create_password_reset_email(username: str, password: str, login_url: str) -> tuple[str, str, str]:
- """Create password reset email content.
-
- Args:
- username: Username of the user
- password: New auto-generated password
- login_url: URL to login page
-
- Returns:
- Tuple of (subject, text_body, html_body)
- """
- subject = "BamBuddy - Your Password Has Been Reset"
-
- text_body = f"""Your BamBuddy password has been reset.
-
-Your login details:
-
-Username: {username}
-New Password: {password}
-
-You can login at: {login_url}
-
-For security reasons, please change your password after logging in.
-
-If you did not request this password reset, please contact your administrator immediately.
-
-Best regards,
-BamBuddy Team
-"""
-
- html_body = f"""
-
-
-
-
-
-
-
-
Password Reset
-
-
-
Your BamBuddy password has been reset.
-
-
-
Username: {username}
-
New Password: {password}
-
-
-
-
-
-
- ⚠️ Security Alert: If you did not request this password reset, please contact your administrator immediately.
-
-
-
-
- Security Note: For security reasons, please change your password after logging in.
-
-
-
- Best regards,
- BamBuddy Team
-
-
-
-
-"""
-
- return subject, text_body, html_body
-
-
def create_password_reset_link_email(username: str, reset_url: str) -> tuple[str, str, str]:
"""Create a password-reset email that contains a secure link (not a plaintext password)."""
subject = "BamBuddy - Password Reset Request"
@@ -455,17 +381,11 @@ BamBuddy Team
async def create_password_reset_link_email_from_template(
db: AsyncSession, username: str, reset_url: str
) -> tuple[str, str, str]:
- """Create password-reset link email, using DB template if configured."""
- template = await get_notification_template(db, "password_reset_link")
- if template:
- variables = {"username": username, "reset_url": reset_url}
- subject = render_template(template.subject or "BamBuddy - Password Reset Request", variables)
- text_body = render_template(template.body or "", variables)
- html_body = render_template(template.html_body or "", variables) if template.html_body else None
- if not html_body:
- _, text_body, html_body = create_password_reset_link_email(username, reset_url)
- return subject, text_body, html_body
- return subject, text_body, html_body
+ """Create the password-reset link email.
+
+ There is no notification template for it (the seeded "password_reset" one is
+ for the old emailed-password flow), so this is always the built-in email.
+ """
return create_password_reset_link_email(username, reset_url)
@@ -531,74 +451,6 @@ async def create_welcome_email_from_template(
return create_welcome_email(username, password, login_url)
-async def create_password_reset_email_from_template(
- db: AsyncSession, username: str, password: str, login_url: str, app_name: str = "BamBuddy"
-) -> tuple[str, str, str]:
- """Create password reset email content using notification template from database.
-
- Args:
- db: Database session
- username: Username of the user
- password: New auto-generated password
- login_url: URL to login page
- app_name: Application name (default: BamBuddy)
-
- Returns:
- Tuple of (subject, text_body, html_body)
- """
- # Try to get template from database
- template = await get_notification_template(db, "password_reset")
-
- if template:
- # Render template with variables
- variables = {
- "app_name": app_name,
- "username": username,
- "password": password,
- "login_url": login_url,
- }
-
- subject = render_template(template.title_template, variables)
- text_body = render_template(template.body_template, variables)
-
- # Create HTML version with embedded login button
- # Escape text_body to prevent XSS vulnerabilities and convert newlines to
tags
- escaped_text_body = html.escape(text_body).replace("\n", "
\n")
- html_body = f"""
-
-
-
-
-
-
-
-
{html.escape(subject)}
-
-
-
{escaped_text_body}
-
-
-
-
-
- ⚠️ Security Alert: If you did not request this password reset, please contact your administrator immediately.
-
-
-
-
-
-"""
-
- logger.info("Using custom password reset email template from database")
- return subject, text_body, html_body
- else:
- # 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,
diff --git a/backend/app/services/external_camera.py b/backend/app/services/external_camera.py
index 7eba3565f..7676361cb 100644
--- a/backend/app/services/external_camera.py
+++ b/backend/app/services/external_camera.py
@@ -153,19 +153,6 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
return None
-def _validate_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> bool:
- """Validate camera URL format (legacy wrapper).
-
- Args:
- url: URL to validate
- allowed_schemes: Tuple of allowed URL schemes
-
- Returns:
- True if URL is valid, False otherwise
- """
- return _sanitize_camera_url(url, allowed_schemes) is not None
-
-
def list_usb_cameras() -> list[dict]:
"""List available USB cameras (V4L2 devices on Linux).
diff --git a/backend/app/services/filament_deficit.py b/backend/app/services/filament_deficit.py
index c6a82c535..4b67121ce 100644
--- a/backend/app/services/filament_deficit.py
+++ b/backend/app/services/filament_deficit.py
@@ -255,11 +255,6 @@ def _material_identity_spoolman(spool: dict | None) -> str:
return f"unmatched:{spool_id}"
-def _ams_id_from_global(global_tray_id: int) -> int:
- """Inverse of ``_global_to_ams_key`` returning ams_id only."""
- return _global_to_ams_key(global_tray_id)[0]
-
-
def _extruder_side_for_ams(
ams_id: int,
ams_extruder_map: dict[str, int],
diff --git a/backend/app/services/label_renderer.py b/backend/app/services/label_renderer.py
index 91d4b775d..b64197a04 100644
--- a/backend/app/services/label_renderer.py
+++ b/backend/app/services/label_renderer.py
@@ -162,11 +162,6 @@ def _color_from_hex(hex_str: str | None, fallback: Color = HexColor(0x808080)) -
return fallback
-def _luminance(color: Color) -> float:
- """Perceived luminance of a ReportLab Color (0–1, WCAG-style approximation)."""
- return 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue
-
-
def _hex_code_label(rgba: str | None) -> str:
"""Format ``data.rgba`` as a printable ``#RRGGBB`` string for the label.
diff --git a/backend/app/services/location_service.py b/backend/app/services/location_service.py
index 8ddcb4b8c..b28a60571 100644
--- a/backend/app/services/location_service.py
+++ b/backend/app/services/location_service.py
@@ -213,21 +213,6 @@ async def count_internal_spools_at_location(db: AsyncSession, location_id: int)
return int(result.scalar() or 0)
-async def count_spools_at_location_by_name(db: AsyncSession, name: str) -> int:
- normalized = name.strip()
- if not normalized:
- return 0
- result = await db.execute(
- select(func.count())
- .select_from(Spool)
- .where(
- Spool.archived_at.is_(None),
- func.lower(func.trim(Spool.storage_location)) == normalized.lower(),
- )
- )
- return int(result.scalar() or 0)
-
-
async def enrich_spool_dicts_with_location_id(db: AsyncSession, spools: list[dict]) -> None:
"""Attach location_id to mapped Spoolman-style spool dicts in place."""
keys = {location_name_key(s["storage_location"]) for s in spools if (s.get("storage_location") or "").strip()}
diff --git a/backend/app/services/network_utils.py b/backend/app/services/network_utils.py
index d6c392307..ab1c1790c 100644
--- a/backend/app/services/network_utils.py
+++ b/backend/app/services/network_utils.py
@@ -382,16 +382,3 @@ def find_interface_for_ip(target_ip: str) -> dict | None:
logger.warning("No interface found for target IP %s", target_ip)
return None
-
-
-def get_other_interfaces(exclude_ip: str) -> list[dict]:
- """Get all interfaces except the one with the given IP.
-
- Args:
- exclude_ip: IP address of interface to exclude
-
- Returns:
- List of interface dicts
- """
- interfaces = get_network_interfaces()
- return [iface for iface in interfaces if iface["ip"] != exclude_ip]
diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py
index 27d50e20a..fc5a71e6d 100644
--- a/backend/app/services/notification_service.py
+++ b/backend/app/services/notification_service.py
@@ -1624,7 +1624,6 @@ class NotificationService:
event_type: str = "unknown",
printer_id: int | None = None,
printer_name: str | None = None,
- force_immediate: bool = False,
image_data: bytes | None = None,
variables: dict | None = None,
):
@@ -2041,7 +2040,6 @@ class NotificationService:
"print_missing_spool_assignment",
printer_id,
printer_name,
- force_immediate=True,
variables=variables,
)
@@ -2074,7 +2072,6 @@ class NotificationService:
"billing_charge_failed",
printer_id,
printer_name,
- force_immediate=True,
variables=variables,
)
@@ -2190,7 +2187,6 @@ class NotificationService:
"plate_not_empty",
printer_id,
printer_name,
- force_immediate=True,
image_data=image_data,
variables=variables,
)
@@ -2297,7 +2293,7 @@ class NotificationService:
threshold: float,
db: AsyncSession,
):
- """Handle AMS high humidity alarm event. Always sends immediately (bypasses digest)."""
+ """Handle AMS high humidity alarm event. Sent immediately, and also in the daily digest if enabled."""
providers = await self._get_providers_for_event(db, "on_ams_humidity_high", printer_id)
if not providers:
return
@@ -2310,7 +2306,6 @@ class NotificationService:
}
title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
- # Alarms always send immediately, bypassing digest mode
await self._send_to_providers(
providers,
title,
@@ -2319,7 +2314,6 @@ class NotificationService:
"ams_humidity_high",
printer_id,
printer_name,
- force_immediate=True,
variables=variables,
)
@@ -2332,7 +2326,7 @@ class NotificationService:
threshold: float,
db: AsyncSession,
):
- """Handle AMS high temperature alarm event. Always sends immediately (bypasses digest)."""
+ """Handle AMS high temperature alarm event. Sent immediately, and also in the daily digest if enabled."""
providers = await self._get_providers_for_event(db, "on_ams_temperature_high", printer_id)
if not providers:
return
@@ -2345,7 +2339,6 @@ class NotificationService:
}
title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
- # Alarms always send immediately, bypassing digest mode
await self._send_to_providers(
providers,
title,
@@ -2354,7 +2347,6 @@ class NotificationService:
"ams_temperature_high",
printer_id,
printer_name,
- force_immediate=True,
variables=variables,
)
@@ -2370,7 +2362,8 @@ class NotificationService:
):
"""Handle automatic drying giving up on one AMS unit (#2770).
- Sent immediately rather than folded into a digest: it reports that
+ Worth sending immediately (as every notification is; a daily digest only
+ repeats it): it reports that
Bambuddy has STOPPED doing something, and a report of inaction that
arrives with tomorrow's summary has already cost the user a day.
"""
@@ -2395,7 +2388,6 @@ class NotificationService:
"ams_drying_suspended",
printer_id,
printer_name,
- force_immediate=True,
variables=variables,
)
@@ -2408,7 +2400,7 @@ class NotificationService:
threshold: float,
db: AsyncSession,
):
- """Handle AMS-HT high humidity alarm event. Always sends immediately (bypasses digest)."""
+ """Handle AMS-HT high humidity alarm event. Sent immediately, and also in the daily digest if enabled."""
providers = await self._get_providers_for_event(db, "on_ams_ht_humidity_high", printer_id)
if not providers:
return
@@ -2422,7 +2414,6 @@ class NotificationService:
# Use the same template as regular AMS (can create separate templates later if needed)
title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
- # Alarms always send immediately, bypassing digest mode
await self._send_to_providers(
providers,
title,
@@ -2431,7 +2422,6 @@ class NotificationService:
"ams_ht_humidity_high",
printer_id,
printer_name,
- force_immediate=True,
variables=variables,
)
@@ -2444,7 +2434,7 @@ class NotificationService:
threshold: float,
db: AsyncSession,
):
- """Handle AMS-HT high temperature alarm event. Always sends immediately (bypasses digest)."""
+ """Handle AMS-HT high temperature alarm event. Sent immediately, and also in the daily digest if enabled."""
providers = await self._get_providers_for_event(db, "on_ams_ht_temperature_high", printer_id)
if not providers:
return
@@ -2458,7 +2448,6 @@ class NotificationService:
# Use the same template as regular AMS (can create separate templates later if needed)
title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
- # Alarms always send immediately, bypassing digest mode
await self._send_to_providers(
providers,
title,
@@ -2467,7 +2456,6 @@ class NotificationService:
"ams_ht_temperature_high",
printer_id,
printer_name,
- force_immediate=True,
variables=variables,
)
@@ -2507,7 +2495,8 @@ class NotificationService:
):
"""A Home Assistant sensor bound to a printer entered its alert state (#1148).
- Sent immediately rather than folded into a digest: the case this exists
+ Worth sending immediately (as every notification is; a daily digest only
+ repeats it): the case this exists
for is an enclosure door left open, which is only worth telling someone
about while they can still act on it.
"""
@@ -2530,7 +2519,6 @@ class NotificationService:
"ha_sensor_alert",
printer_id,
printer_name,
- force_immediate=True,
variables=variables,
)
@@ -2543,7 +2531,7 @@ class NotificationService:
):
"""A Home Assistant sensor bound to a storage location entered its alert state (#2824).
- Sent immediately rather than folded into a digest, for the same reason
+ Worth sending immediately, for the same reason
as on_ha_sensor_alert above: this is the "drybox went stale" case, only
worth acting on while the humidity/temperature is still climbing.
"""
@@ -2568,7 +2556,6 @@ class NotificationService:
message,
db,
"location_ha_sensor_alert",
- force_immediate=True,
variables=variables,
)
diff --git a/backend/app/services/plate_detection.py b/backend/app/services/plate_detection.py
index c12ae07e2..2837a5b41 100644
--- a/backend/app/services/plate_detection.py
+++ b/backend/app/services/plate_detection.py
@@ -134,17 +134,6 @@ class PlateDetector:
paths.append(path)
return paths
- def _get_next_reference_slot(self, printer_id: int) -> Path:
- """Get the path for the next reference image slot (cycles through slots)."""
- _get_calibration_dir().mkdir(parents=True, exist_ok=True)
- # Find first empty slot, or use oldest (slot 0) and shift others
- for i in range(self.MAX_REFERENCES):
- path = _get_calibration_dir() / f"printer_{printer_id}_ref_{i}.jpg"
- if not path.exists():
- return path
- # All slots full - return slot 0 (will be overwritten, but we rotate first)
- return _get_calibration_dir() / f"printer_{printer_id}_ref_0.jpg"
-
def _rotate_references(self, printer_id: int) -> None:
"""Rotate references: delete oldest (0), shift others down."""
# Delete slot 0
diff --git a/backend/app/services/spoolman.py b/backend/app/services/spoolman.py
index 21e181d3c..1a2bac0f2 100644
--- a/backend/app/services/spoolman.py
+++ b/backend/app/services/spoolman.py
@@ -36,34 +36,6 @@ TAG_REFUSAL_RETRY_SECONDS = 600
BAMBU_WEIGHT_USED_BASELINE_KEY = "bambu_weight_used_baseline"
-@dataclass
-class SpoolmanSpool:
- """Represents a spool in Spoolman."""
-
- id: int
- filament_id: int | None
- remaining_weight: float | None
- used_weight: float
- first_used: str | None
- last_used: str | None
- location: str | None
- lot_nr: str | None
- comment: str | None
- extra: dict | None # Contains tag_uid in extra.tag
-
-
-@dataclass
-class SpoolmanFilament:
- """Represents a filament type in Spoolman."""
-
- id: int
- name: str
- vendor_id: int | None
- material: str | None
- color_hex: str | None
- weight: float | None # Net weight in grams
-
-
@dataclass
class AMSTray:
"""Represents an AMS tray with filament data from Bambu printer."""
@@ -1119,15 +1091,6 @@ class SpoolmanClient:
return spool
return None
- def _find_spool_by_location(self, location: str, cached_spools: list[dict] | None) -> dict | None:
- """Return the spool at the exact location string, or None; fallback when RFID is unavailable."""
- if not cached_spools:
- return None
- for spool in cached_spools:
- if spool.get("location") == location:
- return spool
- return None
-
async def find_spools_by_location_prefix(
self, location_prefix: str, cached_spools: list[dict] | None = None
) -> list[dict]:
diff --git a/backend/app/services/virtual_printer/mqtt_server.py b/backend/app/services/virtual_printer/mqtt_server.py
index b64aaa594..10a1c2439 100644
--- a/backend/app/services/virtual_printer/mqtt_server.py
+++ b/backend/app/services/virtual_printer/mqtt_server.py
@@ -82,147 +82,11 @@ MODEL_PRODUCT_NAMES = {
}
-class VirtualPrinterMQTTServer:
- """MQTT broker that accepts connections from slicers.
-
- This is a minimal MQTT broker implementation that:
- - Accepts TLS connections on port 8883
- - Authenticates with username 'bblp' and the configured access code
- - Receives print commands on device/{serial}/request
- - Can publish status on device/{serial}/report
- """
-
- def __init__(
- self,
- serial: str,
- access_code: str,
- cert_path: Path,
- key_path: Path,
- port: int = MQTT_PORT,
- on_print_command: Callable[[str, dict], None] | None = None,
- ):
- """Initialize the MQTT server.
-
- Args:
- serial: Virtual printer serial number
- access_code: Password for authentication
- cert_path: Path to TLS certificate
- key_path: Path to TLS private key
- port: Port to listen on (default 8883)
- on_print_command: Callback when print command received (filename, data)
- """
- self.serial = serial
- self.access_code = access_code
- self.cert_path = cert_path
- self.key_path = key_path
- self.port = port
- self.on_print_command = on_print_command
- self._running = False
- self._broker = None
- self._broker_task = None
-
- async def start(self) -> None:
- """Start the MQTT broker."""
- if self._running:
- return
-
- # Try to import amqtt
- try:
- from amqtt.broker import Broker
- except ImportError:
- logger.error("amqtt not installed. Run: pip install amqtt")
- return
-
- logger.info("Starting virtual printer MQTT broker on port %s", self.port)
-
- # Build broker configuration
- config = {
- "listeners": {
- "default": {
- "type": "tcp",
- "bind": f"0.0.0.0:{self.port}",
- "ssl": "on",
- "certfile": str(self.cert_path),
- "keyfile": str(self.key_path),
- },
- },
- "auth": {
- "allow-anonymous": False,
- "plugins": ["auth_custom"],
- },
- "topic-check": {
- "enabled": False, # Allow any topic
- },
- }
-
- try:
- self._running = True
-
- # Create and start broker
- self._broker = Broker(config)
-
- # Register custom auth plugin
- self._broker.plugins_manager.plugins_handlers["auth_custom"] = self._authenticate
-
- # Start the broker
- await self._broker.start()
- logger.info("MQTT broker started on port %s", self.port)
-
- # Keep running
- while self._running:
- await asyncio.sleep(1)
-
- except OSError as e:
- if e.errno == 98: # Address already in use
- logger.error("MQTT port %s is already in use", self.port)
- else:
- logger.error("MQTT broker error: %s", e)
- except asyncio.CancelledError:
- logger.debug("MQTT broker task cancelled")
- except Exception as e:
- logger.error("MQTT broker error: %s", e)
- finally:
- await self.stop()
-
- async def _authenticate(self, session) -> bool:
- """Authenticate MQTT connection.
-
- Args:
- session: MQTT session with username/password
-
- Returns:
- True if authentication successful
- """
- username = getattr(session, "username", None)
- password = getattr(session, "password", None)
-
- # Bambu slicers use 'bblp' as username and access code as password
- if username == "bblp" and password == self.access_code:
- logger.debug("MQTT client authenticated from %s", session.remote_address)
- return True
-
- logger.warning("MQTT auth failed for user '%s' from %s", username, session.remote_address)
- return False
-
- async def stop(self) -> None:
- """Stop the MQTT broker."""
- logger.info("Stopping MQTT broker")
- self._running = False
-
- if self._broker:
- try:
- await self._broker.shutdown()
- except OSError as e:
- logger.debug("Error shutting down MQTT broker: %s", e)
- self._broker = None
-
-
class SimpleMQTTServer:
- """Simplified MQTT server using raw sockets.
+ """The virtual printer's MQTT server, on raw sockets.
- This is a fallback implementation that handles basic MQTT protocol
- without requiring the amqtt library. It's less feature-complete but
- more lightweight.
+ Handles the part of the MQTT protocol slicers use with a printer; not a
+ general-purpose broker.
"""
def __init__(
diff --git a/backend/app/services/virtual_printer/tcp_proxy.py b/backend/app/services/virtual_printer/tcp_proxy.py
index a70cf5620..d6b6b8ee4 100644
--- a/backend/app/services/virtual_printer/tcp_proxy.py
+++ b/backend/app/services/virtual_printer/tcp_proxy.py
@@ -14,7 +14,6 @@ rewrite the printer's real IP with the proxy's bind IP in MQTT payloads.
import asyncio
import logging
-import random
import re
import ssl
import subprocess
@@ -24,44 +23,6 @@ from pathlib import Path
logger = logging.getLogger(__name__)
-class _SessionReuseSSLContext:
- """Proxy around SSLContext that injects a TLS session into wrap_bio().
-
- vsFTPd (used by some Bambu printers like X1C) requires TLS session reuse
- on FTP data channels — the data connection must reuse the TLS session from
- the control channel. Without this, the printer rejects the data connection
- with "522 SSL connection failed: session reuse required".
-
- asyncio's open_connection() calls SSLContext.wrap_bio() internally but
- doesn't expose a session parameter. This wrapper intercepts wrap_bio()
- to inject the saved control-channel session, enabling session reuse.
- """
-
- def __init__(self, ctx: ssl.SSLContext, session: ssl.SSLSession) -> None:
- object.__setattr__(self, "_ctx", ctx)
- object.__setattr__(self, "_session", session)
-
- def __getattr__(self, name: str) -> object:
- return getattr(self._ctx, name)
-
- def wrap_bio(
- self,
- incoming: ssl.MemoryBIO,
- outgoing: ssl.MemoryBIO,
- server_side: bool = False,
- server_hostname: str | None = None,
- **kwargs: object,
- ) -> ssl.SSLObject:
- return self._ctx.wrap_bio(
- incoming,
- outgoing,
- server_side=server_side,
- server_hostname=server_hostname,
- session=self._session,
- **kwargs,
- )
-
-
def detect_port_redirect(port: int) -> int | None:
"""Detect if iptables redirects a port to another port.
@@ -805,636 +766,6 @@ class TCPProxy:
logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
-class FTPTLSProxy(TLSProxy):
- """FTP-aware TLS proxy that handles passive data connections.
-
- Extends TLSProxy to intercept PASV/EPSV responses on the FTP control
- channel, dynamically create TLS data proxies on local ports, and rewrite
- the responses so the slicer connects to the proxy instead of the printer.
-
- Without this, FTP passive data connections bypass the proxy and go directly
- to the printer, which fails when the slicer can't reach the printer's IP.
- """
-
- PASV_PORT_MIN = 50000
- PASV_PORT_MAX = 50100
-
- async def stop(self) -> None:
- """Stop proxy and clean up data connection servers."""
- # Cancel any pending auto_close timeouts so they don't outlive the
- # proxy holding a dead server reference. Without this, up to 101
- # tasks lingered for ~60 s after stop(), each gripping a server
- # ref + an active_connections slot; under rapid mode-switch the
- # ports stayed bound long enough to fail the next start().
- for task in list(self._auto_close_tasks):
- task.cancel()
- if self._auto_close_tasks:
- await asyncio.gather(*self._auto_close_tasks, return_exceptions=True)
- self._auto_close_tasks.clear()
- # Close all data servers
- for server in list(self._data_servers):
- try:
- server.close()
- await server.wait_closed()
- except OSError:
- pass # Best-effort cleanup of data proxy servers
- self._data_servers.clear()
- await super().stop()
-
- async def start(self) -> None:
- """Start the FTP TLS proxy."""
- self._data_servers: list[asyncio.Server] = []
- self._auto_close_tasks: list[asyncio.Task] = []
- await super().start()
-
- async def _handle_client(
- self,
- client_reader: asyncio.StreamReader,
- client_writer: asyncio.StreamWriter,
- ) -> None:
- """Handle FTP client with PASV/EPSV-aware response forwarding."""
- peername = client_writer.get_extra_info("peername")
- client_id = f"{peername[0]}:{peername[1]}" if peername else "unknown"
-
- logger.info("%s proxy: client connected from %s", self.name, client_id)
-
- if self.on_connect:
- try:
- self.on_connect(client_id)
- except Exception:
- pass # Ignore connect callback errors; connection proceeds regardless
-
- # Determine our local IP from the control connection socket
- sockname = client_writer.get_extra_info("sockname")
- local_ip = sockname[0] if sockname else "0.0.0.0" # nosec B104
- if local_ip in ("0.0.0.0", "::"): # nosec B104
- local_ip = "127.0.0.1"
-
- # Connect to target printer with TLS
- try:
- printer_reader, printer_writer = await asyncio.wait_for(
- asyncio.open_connection(
- self.target_host,
- self.target_port,
- ssl=self._client_ssl_context,
- ),
- timeout=10.0,
- )
- logger.info("%s proxy: connected to printer %s:%s", self.name, self.target_host, self.target_port)
- except TimeoutError:
- logger.error("%s proxy: timeout connecting to %s:%s", self.name, self.target_host, self.target_port)
- client_writer.close()
- await client_writer.wait_closed()
- return
- except ssl.SSLError as e:
- logger.error(
- "%s proxy: SSL error connecting to %s:%s: %s", self.name, self.target_host, self.target_port, e
- )
- client_writer.close()
- await client_writer.wait_closed()
- return
- except OSError as e:
- logger.error("%s proxy: failed to connect to %s:%s: %s", self.name, self.target_host, self.target_port, e)
- client_writer.close()
- await client_writer.wait_closed()
- return
-
- # Capture the TLS session from the control channel for data channel
- # reuse. vsFTPd (X1C) requires require_ssl_reuse — the data connection
- # must present the same TLS session as the control channel.
- ctrl_ssl_object = printer_writer.get_extra_info("ssl_object")
- ctrl_tls_session = ctrl_ssl_object.session if ctrl_ssl_object else None
- if ctrl_tls_session:
- logger.debug("%s proxy: captured TLS session for data channel reuse", self.name)
-
- # Track data channel protection level per session.
- # PROT C = cleartext data, PROT P = TLS data.
- # Default to cleartext — many Bambu printers (A1, H2D) use PROT C.
- # If the slicer sends PROT P, we switch to TLS for data connections.
- session_state: dict[str, str | ssl.SSLSession] = {"prot": "C"}
- if ctrl_tls_session:
- session_state["tls_session"] = ctrl_tls_session
-
- # Client→Printer: intercept EPSV and replace with PASV
- # EPSV responses only contain a port (no IP), so the slicer reuses
- # the control connection IP. If that IP is the real printer (via
- # iptables REDIRECT), the data connection bypasses the proxy.
- # PASV responses include an explicit IP that we can rewrite.
- client_to_printer = asyncio.create_task(
- self._forward_ftp_commands(client_reader, printer_writer, f"{client_id}→printer", session_state),
- name=f"{self.name}_c2p_{client_id}",
- )
- # Printer→Client: intercept PASV/EPSV responses
- printer_to_client = asyncio.create_task(
- self._forward_ftp_control(printer_reader, client_writer, f"printer→{client_id}", local_ip, session_state),
- name=f"{self.name}_p2c_{client_id}",
- )
-
- self._active_connections[client_id] = (client_to_printer, printer_to_client)
-
- try:
- done, pending = await asyncio.wait(
- [client_to_printer, printer_to_client],
- return_when=asyncio.FIRST_COMPLETED,
- )
- for task in pending:
- task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass # Expected when cancelling the other forwarding direction
-
- except Exception as e:
- logger.debug("%s proxy connection error: %s", self.name, e)
- finally:
- self._active_connections.pop(client_id, None)
-
- for writer in [client_writer, printer_writer]:
- try:
- writer.close()
- await writer.wait_closed()
- except OSError:
- pass # Best-effort connection cleanup; peer may have disconnected
-
- logger.info("%s proxy: client %s disconnected", self.name, client_id)
-
- if self.on_disconnect:
- try:
- self.on_disconnect(client_id)
- except Exception:
- pass # Ignore disconnect callback errors; cleanup continues
-
- async def _forward_ftp_commands(
- self,
- reader: asyncio.StreamReader,
- writer: asyncio.StreamWriter,
- direction: str,
- session_state: dict[str, str | ssl.SSLSession],
- ) -> None:
- """Forward FTP client commands, replacing EPSV with PASV.
-
- EPSV responses only contain a port number — the client reuses the
- control connection IP for data. When the control IP is the real
- printer (due to iptables REDIRECT), EPSV data connections bypass
- the proxy. PASV responses include an explicit IP that the proxy
- can rewrite to its own address.
-
- Also tracks PROT P/C commands to know whether data connections
- should use TLS or cleartext.
- """
- buffer = b""
- total_bytes = 0
- try:
- while self._running:
- data = await reader.read(65536)
- if not data:
- break
-
- total_bytes += len(data)
- buffer += data
- output = b""
-
- while b"\r\n" in buffer:
- idx = buffer.index(b"\r\n")
- line = buffer[:idx]
- buffer = buffer[idx + 2 :]
-
- cmd_upper = line.strip().upper()
-
- # Track PROT level for data channel encryption
- if cmd_upper == b"PROT P":
- session_state["prot"] = "P"
- logger.info("FTP data protection: PROT P (TLS)")
- elif cmd_upper == b"PROT C":
- session_state["prot"] = "C"
- logger.info("FTP data protection: PROT C (cleartext)")
-
- output += line + b"\r\n"
-
- if output:
- writer.write(output)
- await writer.drain()
-
- logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
-
- except asyncio.CancelledError:
- pass # Expected when the other forwarding direction closes first
- except ConnectionResetError:
- logger.debug("%s proxy %s: connection reset", self.name, direction)
- except BrokenPipeError:
- logger.debug("%s proxy %s: broken pipe", self.name, direction)
- except OSError as e:
- logger.debug("%s proxy %s error: %s", self.name, direction, e)
-
- if buffer:
- try:
- writer.write(buffer)
- await writer.drain()
- except OSError:
- pass # Best-effort flush of remaining FTP command data
-
- logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
-
- async def _forward_ftp_control(
- self,
- reader: asyncio.StreamReader,
- writer: asyncio.StreamWriter,
- direction: str,
- local_ip: str,
- session_state: dict[str, str | ssl.SSLSession],
- ) -> None:
- """Forward FTP control channel responses, rewriting PASV/EPSV.
-
- FTP control channel is line-based (\\r\\n terminated). We buffer data
- and process complete lines, intercepting 227 (PASV) and 229 (EPSV)
- responses to create local data proxies.
- """
- buffer = b""
- total_bytes = 0
-
- try:
- while self._running:
- data = await reader.read(65536)
- if not data:
- break
-
- total_bytes += len(data)
- buffer += data
- output = b""
-
- # Process all complete lines
- while b"\r\n" in buffer:
- idx = buffer.index(b"\r\n")
- line = buffer[:idx]
- buffer = buffer[idx + 2 :]
-
- rewritten = await self._maybe_rewrite_pasv(line, local_ip, session_state)
- output += rewritten + b"\r\n"
-
- if output:
- writer.write(output)
- await writer.drain()
-
- logger.debug("%s proxy %s: %s bytes", self.name, direction, len(data))
-
- except asyncio.CancelledError:
- pass # Expected when the other forwarding direction closes first
- except ConnectionResetError:
- logger.debug("%s proxy %s: connection reset", self.name, direction)
- except BrokenPipeError:
- logger.debug("%s proxy %s: broken pipe", self.name, direction)
- except OSError as e:
- logger.debug("%s proxy %s error: %s", self.name, direction, e)
-
- # Flush any remaining buffered data
- if buffer:
- try:
- writer.write(buffer)
- await writer.drain()
- except OSError:
- pass # Best-effort flush of remaining FTP control data
-
- logger.debug("%s proxy %s: total %s bytes", self.name, direction, total_bytes)
-
- async def _maybe_rewrite_pasv(
- self, line: bytes, local_ip: str, session_state: dict[str, str | ssl.SSLSession]
- ) -> bytes:
- """Rewrite PASV/EPSV response to point to a local data proxy."""
- try:
- text = line.decode("utf-8")
- except UnicodeDecodeError:
- return line
-
- # 227 Entering Passive Mode (h1,h2,h3,h4,p1,p2)
- if text.startswith("227 "):
- match = re.search(r"\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", text)
- if match:
- h1, h2, h3, h4, p1, p2 = (int(x) for x in match.groups())
- printer_ip = f"{h1}.{h2}.{h3}.{h4}"
- printer_port = p1 * 256 + p2
-
- local_port = await self._create_data_proxy(printer_ip, printer_port, session_state)
- if local_port:
- ip_parts = local_ip.split(".")
- lp1 = local_port // 256
- lp2 = local_port % 256
- rewritten = (
- f"227 Entering Passive Mode "
- f"({ip_parts[0]},{ip_parts[1]},{ip_parts[2]},{ip_parts[3]},{lp1},{lp2})"
- )
- logger.info("FTP PASV rewrite: %s:%s → %s:%s", printer_ip, printer_port, local_ip, local_port)
- return rewritten.encode("utf-8")
- else:
- logger.error("FTP PASV: failed to create data proxy for %s:%s", printer_ip, printer_port)
- else:
- logger.warning("FTP PASV: 227 response didn't match expected format: %s", text[:100])
-
- # 229 Entering Extended Passive Mode (|||port|)
- elif text.startswith("229 "):
- match = re.search(r"\(\|\|\|(\d+)\|\)", text)
- if match:
- printer_port = int(match.group(1))
-
- local_port = await self._create_data_proxy(self.target_host, printer_port, session_state)
- if local_port:
- rewritten = f"229 Entering Extended Passive Mode (|||{local_port}|)"
- logger.info("FTP EPSV rewrite: port %s → %s", printer_port, local_port)
- return rewritten.encode("utf-8")
- else:
- logger.error("FTP EPSV: failed to create data proxy for port %s", printer_port)
- else:
- logger.warning("FTP EPSV: 229 response didn't match expected format: %s", text[:100])
-
- return line
-
- async def _create_data_proxy(
- self, printer_ip: str, printer_port: int, session_state: dict[str, str | ssl.SSLSession]
- ) -> int | None:
- """Create a one-shot proxy for an FTP data connection.
-
- Prefers the printer's original passive port so the port number stays
- the same in the rewritten PASV/EPSV response. This is critical when
- the slicer's FTP bounce-attack protection overrides the IP in the PASV
- response: the slicer connects to :, and if iptables
- REDIRECT maps that port to the local machine, the data proxy must be
- listening on the *same* port number.
-
- Falls back to a random port if the original is unavailable.
-
- Uses TLS or cleartext based on the session's PROT level:
- - PROT P: TLS on both slicer and printer data connections
- - PROT C: cleartext on both sides (common for A1/H2D printers)
-
- Returns the local port number, or None if binding failed.
- """
- use_tls = session_state.get("prot") == "P"
- logger.info(
- "FTP data proxy: creating data proxy for %s:%s (printer-side %s)",
- printer_ip,
- printer_port,
- "TLS" if use_tls else "cleartext",
- )
-
- # Get control channel TLS session for data channel reuse
- tls_session = session_state.get("tls_session") if use_tls else None
-
- # Try the printer's original port first — this ensures the port
- # matches even when bounce protection or iptables REDIRECT is in play.
- try:
- await self._start_data_proxy_server(printer_port, printer_ip, printer_port, use_tls, tls_session)
- logger.info("FTP data proxy: using printer's port %s", printer_port)
- return printer_port
- except OSError as e:
- logger.debug(
- "FTP data proxy: printer port %s unavailable (%s), trying random",
- printer_port,
- e,
- )
-
- for _attempt in range(10):
- port = random.randint(self.PASV_PORT_MIN, self.PASV_PORT_MAX)
- try:
- await self._start_data_proxy_server(port, printer_ip, printer_port, use_tls, tls_session)
- logger.info("FTP data proxy: using random port %s", port)
- return port
- except OSError:
- continue
-
- logger.error("Failed to bind FTP data proxy port after 10 attempts")
- return None
-
- async def _start_data_proxy_server(
- self,
- port: int,
- printer_ip: str,
- printer_port: int,
- use_tls: bool,
- tls_session: ssl.SSLSession | None = None,
- ) -> None:
- """Start a one-shot server for one FTP data connection.
-
- When the slicer connects, immediately connects to the printer's data
- port and buffers any slicer data until the printer connection is ready.
- This handles zero-byte uploads (verify_job) where the slicer closes
- the data channel before a naive proxy would finish its TLS handshake.
-
- The slicer-side listener is ALWAYS cleartext. Even when the slicer
- sends PROT P on the control channel, Bambu Studio does not perform
- a TLS handshake on the data connection — it relies on the implicit
- FTPS control channel for authentication and sends data unencrypted.
-
- The printer-side outbound connection follows the PROT level:
- - PROT P (use_tls=True): TLS to the printer's data port
- - PROT C (use_tls=False): cleartext to the printer's data port
-
- This mirrors the control channel's TLS-termination architecture.
-
- Raises OSError if the port is already in use.
- """
- connected = asyncio.Event()
- server_holder: list[asyncio.Server] = []
-
- # Slicer side: ALWAYS cleartext — Bambu Studio does not do TLS on
- # the data channel even after sending PROT P.
- # Printer side: TLS if PROT P, cleartext if PROT C.
- # For TLS data connections, wrap the SSL context to reuse the
- # control channel's TLS session if available. vsFTPd (X1C) requires
- # require_ssl_reuse — without this, data connections are rejected
- # with "522 SSL connection failed: session reuse required".
- if use_tls and tls_session:
- client_ssl = _SessionReuseSSLContext(self._client_ssl_context, tls_session)
- logger.debug("FTP data proxy: using TLS session reuse for port %s", port)
- else:
- client_ssl = self._client_ssl_context if use_tls else None
-
- # Slicer side is ALWAYS cleartext — Bambu Studio does not do TLS on
- # the data channel even after PROT P (confirmed for both H2D and X1C).
- printer_mode = "TLS" if use_tls else "cleartext"
-
- async def handle_data(
- client_reader: asyncio.StreamReader,
- client_writer: asyncio.StreamWriter,
- ) -> None:
- """Handle one FTP data connection, then close the server."""
- peername = client_writer.get_extra_info("peername")
- data_client = f"{peername[0]}:{peername[1]}" if peername else "unknown"
- logger.info(
- "FTP data proxy port %s (slicer=cleartext, printer=%s): client connected from %s, bridging to %s:%s",
- port,
- printer_mode,
- data_client,
- printer_ip,
- printer_port,
- )
- connected.set()
- # One-shot: close server after accepting first connection
- if server_holder:
- server_holder[0].close()
-
- printer_writer = None
- try:
- # Buffer any slicer data while connecting to printer.
- # This handles the race where the slicer sends data (or closes
- # for zero-byte files) before the TLS handshake completes.
- slicer_buffer = bytearray()
- slicer_eof = False
-
- async def buffer_slicer():
- nonlocal slicer_eof
- while True:
- chunk = await client_reader.read(65536)
- if not chunk:
- slicer_eof = True
- return
- slicer_buffer.extend(chunk)
-
- buffer_task = asyncio.create_task(buffer_slicer())
-
- # Connect to printer's data port
- printer_reader, printer_writer = await asyncio.wait_for(
- asyncio.open_connection(printer_ip, printer_port, ssl=client_ssl),
- timeout=10.0,
- )
- logger.info(
- "FTP data proxy port %s (printer=%s): connected to printer %s:%s",
- port,
- printer_mode,
- printer_ip,
- printer_port,
- )
-
- # Stop buffering
- buffer_task.cancel()
- try:
- await buffer_task
- except asyncio.CancelledError:
- pass
-
- # Flush buffered slicer data to printer
- logger.info(
- "FTP data proxy port %s: buffer=%s bytes, slicer_eof=%s",
- port,
- len(slicer_buffer),
- slicer_eof,
- )
- if slicer_buffer:
- printer_writer.write(bytes(slicer_buffer))
- await printer_writer.drain()
-
- # Forward remaining slicer data to printer, then close the
- # printer side to signal upload complete.
- #
- # Bambu Studio does NOT close the FTP data channel after sending
- # STOR data — it keeps the connection open and waits for the
- # printer to close its side + send 226 on the control channel.
- # A naive bidirectional proxy deadlocks here because the proxy
- # waits for the slicer EOF that never comes.
- #
- # Fix: read slicer data with an idle timeout. Once data has been
- # received and the slicer goes quiet, close the printer side so
- # the printer can send 226. For RETR (download), the printer
- # sends data and closes — the slicer reads until EOF — so this
- # unidirectional approach works for both directions.
- total_c2p = len(slicer_buffer)
- if not slicer_eof:
- # Read remaining slicer data with idle detection.
- # Must be short — Bambu Studio expects 226 almost instantly
- # after sending data. Too long and the slicer times out.
- idle_timeout = 0.3
- while True:
- try:
- chunk = await asyncio.wait_for(client_reader.read(65536), timeout=idle_timeout)
- except TimeoutError:
- if total_c2p > 0:
- # Slicer sent data then went idle — upload done
- logger.debug(
- "FTP data proxy port %s: slicer idle after %s bytes, closing printer side",
- port,
- total_c2p,
- )
- break
- continue # No data yet, keep waiting
- if not chunk:
- break # Slicer closed
- printer_writer.write(chunk)
- await printer_writer.drain()
- total_c2p += len(chunk)
-
- logger.debug("FTP proxy data_c2p: total %s bytes", total_c2p)
-
- # Close printer side to signal upload complete.
- # For TLS, close() sends close_notify which the printer treats
- # as end-of-data. The printer then sends 226 on the control
- # channel. For RETR, this is a no-op since the printer closes
- # first and we'd have exited the loop above via EOF.
- try:
- printer_writer.close()
- await printer_writer.wait_closed()
- except OSError:
- pass
-
- # Wait for 226 response to propagate through the FTP control
- # channel before closing the slicer's data channel.
- #
- # Without this delay, the data channel FIN arrives at the
- # slicer before the 226 response on the control channel.
- # BambuStudio reacts to the data channel FIN within <1ms
- # by sending QUIT + closing the control channel — before
- # 226 arrives (~2-3ms network RTT). This causes verify_job
- # to be treated as failed and shows the login modal.
- #
- # In a direct connection, the printer sends 226 AND closes
- # the data channel simultaneously, so the slicer gets both
- # at once. The delay here emulates that timing.
- if total_c2p > 0:
- await asyncio.sleep(0.5)
-
- except Exception as e:
- logger.error("FTP data proxy port %s: error: %s", port, e)
- finally:
- for w in [client_writer, printer_writer]:
- if w:
- try:
- w.close()
- await w.wait_closed()
- except OSError:
- pass # Best-effort data connection cleanup
- logger.info("FTP data proxy port %s: connection closed", port)
-
- server = await asyncio.start_server(
- handle_data,
- "0.0.0.0", # nosec B104
- port,
- # No TLS on slicer side — Bambu Studio doesn't do TLS on data
- # channel even after PROT P (confirmed by connection hang test).
- )
- server_holder.append(server)
- self._data_servers.append(server)
-
- # Auto-close after 60s if no connection arrives
- async def auto_close() -> None:
- try:
- await asyncio.wait_for(connected.wait(), timeout=60.0)
- except TimeoutError:
- logger.debug("FTP data proxy on port %s timed out, closing", port)
- try:
- server.close()
- await server.wait_closed()
- except OSError:
- pass # Best-effort timeout cleanup
- finally:
- if server in self._data_servers:
- self._data_servers.remove(server)
-
- # Track the auto_close task so stop() can cancel it; otherwise the
- # 60 s timeout would hold a server reference past proxy teardown.
- ac_task = asyncio.create_task(auto_close(), name=f"ftp_data_timeout_{port}")
- self._auto_close_tasks.append(ac_task)
- ac_task.add_done_callback(lambda t, tasks=self._auto_close_tasks: tasks.remove(t) if t in tasks else None)
-
- logger.debug("FTP data proxy: port %s → %s:%s", port, printer_ip, printer_port)
-
-
class SlicerProxyManager:
"""Manages FTP and MQTT TLS proxies for a single printer target."""
diff --git a/backend/tests/unit/services/test_email_service.py b/backend/tests/unit/services/test_email_service.py
index ea235046d..e001c9845 100644
--- a/backend/tests/unit/services/test_email_service.py
+++ b/backend/tests/unit/services/test_email_service.py
@@ -12,7 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.notification_template import NotificationTemplate
from backend.app.services.email_service import (
- create_password_reset_email_from_template,
create_welcome_email_from_template,
generate_secure_password,
render_template,
@@ -68,46 +67,6 @@ class TestEmailTemplateFormatting:
assert "Hello testuser!
" in html_body
assert "Your password is: testpass123
" in html_body
- @pytest.mark.asyncio
- async def test_password_reset_email_newlines_converted_to_br(self):
- """Verify that newlines in password reset email body are converted to
tags."""
- # Mock database session
- db = AsyncMock(spec=AsyncSession)
-
- # Mock template with newlines
- template = NotificationTemplate(
- event_type="password_reset",
- name="Password Reset",
- title_template="{app_name} - Password Reset",
- body_template="Hello {username},\n\nYour password has been reset.\nNew password: {password}\n\nLogin at: {login_url}",
- is_default=True,
- )
-
- # Patch get_notification_template to return our template
- with patch("backend.app.services.email_service.get_notification_template", return_value=template):
- # Generate email
- subject, text_body, html_body = await create_password_reset_email_from_template(
- db=db,
- username="testuser",
- password="newpass456",
- login_url="http://example.com/login",
- app_name="TestApp",
- )
-
- # Verify subject
- assert subject == "TestApp - Password Reset"
-
- # Verify text body has newlines
- assert "\n\n" in text_body
- assert "Hello testuser," in text_body
-
- # Verify HTML body has
tags
- assert "
" in html_body
- # Should not use white-space: pre-wrap
- assert "white-space: pre-wrap" not in html_body
- # Should have security alert
- assert "Security Alert" in html_body
-
@pytest.mark.asyncio
async def test_email_header_padding(self):
"""Verify that email header has proper padding to prevent cutoff."""
diff --git a/backend/tests/unit/services/test_notification_service.py b/backend/tests/unit/services/test_notification_service.py
index 73058fa8d..7caa70d3e 100644
--- a/backend/tests/unit/services/test_notification_service.py
+++ b/backend/tests/unit/services/test_notification_service.py
@@ -130,7 +130,6 @@ class TestNotificationService:
},
)
assert mock_send.await_args.args[4:7] == ("billing_charge_failed", 7, "Printer B")
- assert mock_send.await_args.kwargs["force_immediate"] is True
# ========================================================================
# Tests for on_print_complete (status routing)
@@ -347,9 +346,6 @@ class TestNotificationService:
)
mock_send.assert_called_once()
- # Verify force_immediate is True for alarms
- call_kwargs = mock_send.call_args[1]
- assert call_kwargs.get("force_immediate") is True
@pytest.mark.asyncio
async def test_on_ams_temperature_high_sends_notification(self, service, mock_provider, mock_db):
@@ -374,9 +370,6 @@ class TestNotificationService:
)
mock_send.assert_called_once()
- # Verify force_immediate is True for alarms
- call_kwargs = mock_send.call_args[1]
- assert call_kwargs.get("force_immediate") is True
@pytest.mark.asyncio
async def test_ams_alarm_skipped_when_toggle_disabled(self, service, mock_provider, mock_db):
@@ -431,33 +424,6 @@ class TestNotificationService:
# but internally it will queue instead of send immediately
mock_send.assert_called_once()
- @pytest.mark.asyncio
- async def test_force_immediate_bypasses_digest(self, service, mock_provider, mock_db):
- """Verify force_immediate=True bypasses digest mode."""
- mock_provider.daily_digest_enabled = True
- mock_provider.on_ams_humidity_high = True
-
- with (
- patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
- patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
- patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
- ):
- mock_get.return_value = [mock_provider]
- mock_build.return_value = ("Alert", "Alert message")
-
- await service.on_ams_humidity_high(
- printer_id=1,
- printer_name="Test",
- ams_label="AMS-A",
- humidity=75.0,
- threshold=60.0,
- db=mock_db,
- )
-
- # Verify force_immediate is passed
- call_kwargs = mock_send.call_args[1]
- assert call_kwargs.get("force_immediate") is True
-
class TestDigestModeAlwaysSendsImmediately:
"""CRITICAL: Tests that notifications always send immediately regardless of digest setting."""
@@ -2437,9 +2403,6 @@ class TestPlateNotEmptyNotifications:
mock_get.assert_called_once()
mock_send.assert_called_once()
- # Verify force_immediate is True (critical alert)
- call_kwargs = mock_send.call_args[1]
- assert call_kwargs.get("force_immediate") is True
@pytest.mark.asyncio
async def test_on_plate_not_empty_skipped_when_disabled(self, service, mock_provider, mock_db):
diff --git a/backend/tests/unit/services/test_p2s_accessory_fans.py b/backend/tests/unit/services/test_p2s_accessory_fans.py
index df5ef23fc..6b6509bb5 100644
--- a/backend/tests/unit/services/test_p2s_accessory_fans.py
+++ b/backend/tests/unit/services/test_p2s_accessory_fans.py
@@ -284,16 +284,10 @@ class TestLeftAuxFanCommand:
assert mqtt_client.set_fan_speed(10, 204) is True
assert sent == ["M106 P10 S204"]
- def test_set_left_aux_fan_helper(self, mqtt_client, monkeypatch):
- sent = []
- monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
- assert mqtt_client.set_left_aux_fan(255) is True
- assert sent == ["M106 P10 S255"]
-
def test_speed_clamped_to_255(self, mqtt_client, monkeypatch):
sent = []
monkeypatch.setattr(mqtt_client, "send_gcode", lambda g: sent.append(g) or True)
- mqtt_client.set_left_aux_fan(999)
+ mqtt_client.set_fan_speed(10, 999)
assert sent == ["M106 P10 S255"]
def test_invalid_fan_index_rejected(self, mqtt_client, monkeypatch):
diff --git a/backend/tests/unit/test_postgres_restore_drop_cascade.py b/backend/tests/unit/test_postgres_restore_drop_cascade.py
index 7d7172b3d..f19074db3 100644
--- a/backend/tests/unit/test_postgres_restore_drop_cascade.py
+++ b/backend/tests/unit/test_postgres_restore_drop_cascade.py
@@ -95,7 +95,7 @@ async def test_restore_drops_tables_with_cascade_not_metadata_drop_all():
"backend.app.core.database._create_engine",
new=MagicMock(return_value=mock_engine),
):
- await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+ await settings_module._import_sqlite_to_postgres(sqlite_path)
# 1. CASCADE drop is emitted, hitting every public-schema table.
cascade_drops = [s for s in executed_sql if "CASCADE" in s and "pg_tables" in s]
@@ -157,7 +157,7 @@ async def test_restore_cascade_drop_targets_only_public_schema():
"backend.app.core.database._create_engine",
new=MagicMock(return_value=mock_engine),
):
- await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+ await settings_module._import_sqlite_to_postgres(sqlite_path)
cascade = next((s for s in executed_sql if "CASCADE" in s), None)
assert cascade is not None
@@ -236,7 +236,7 @@ async def test_restore_drops_every_foreign_key_before_importing_rows():
"backend.app.core.database._create_engine",
new=MagicMock(return_value=_mock_pg_engine(executed_sql)),
):
- await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+ await settings_module._import_sqlite_to_postgres(sqlite_path)
fk_drops = [i for i, s in enumerate(executed_sql) if "pg_constraint" in s and "DROP CONSTRAINT" in s]
assert fk_drops, (
@@ -283,9 +283,9 @@ async def test_restore_never_mutates_the_process_wide_orm_metadata(create_all_fa
with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
if create_all_fails:
with pytest.raises(RuntimeError, match="create_all exploded"):
- await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+ await settings_module._import_sqlite_to_postgres(sqlite_path)
else:
- await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+ await settings_module._import_sqlite_to_postgres(sqlite_path)
assert _fk_names(table) == before, "The restore must not add or remove constraints on the shared ORM metadata"
finally:
@@ -314,7 +314,7 @@ async def test_unrestorable_fk_is_reported_by_its_columns(caplog):
patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)),
caplog.at_level(logging.INFO, logger="backend.app.api.routes.settings"),
):
- await settings_module._import_sqlite_to_postgres(sqlite_path, "postgresql+asyncpg://test/test")
+ await settings_module._import_sqlite_to_postgres(sqlite_path)
warning = next((r.getMessage() for r in caplog.records if r.levelno == logging.WARNING), None)
assert warning is not None, "a failed FK restore must be reported"
diff --git a/backend/tests/unit/test_restore_schema_compat.py b/backend/tests/unit/test_restore_schema_compat.py
index 0349409f9..010dee60d 100644
--- a/backend/tests/unit/test_restore_schema_compat.py
+++ b/backend/tests/unit/test_restore_schema_compat.py
@@ -250,7 +250,7 @@ async def test_the_import_refuses_before_it_drops_anything(tmp_path):
patch("backend.app.core.database._create_engine", new=create_engine),
pytest.raises(BackupSchemaIncompatible),
):
- await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test")
+ await settings_module._import_sqlite_to_postgres(path)
assert executed == [], f"SQL ran against the destination before the refusal: {executed}"
create_engine.assert_not_called()
@@ -272,7 +272,7 @@ async def test_a_missing_defaulted_column_is_inserted_with_its_default(tmp_path)
engine, executed = _mock_engine()
with patch("backend.app.core.database._create_engine", new=MagicMock(return_value=engine)):
- await settings_module._import_sqlite_to_postgres(path, "postgresql+asyncpg://test/test")
+ await settings_module._import_sqlite_to_postgres(path)
inserts = [(sql, params) for sql, params in executed if sql.startswith("INSERT INTO cost_centers")]
assert inserts, f"no INSERT was emitted: {[sql[:60] for sql, _ in executed]}"
diff --git a/backend/tests/unit/test_route_auth_coverage.py b/backend/tests/unit/test_route_auth_coverage.py
index 6f59c7755..1c3c1c75e 100644
--- a/backend/tests/unit/test_route_auth_coverage.py
+++ b/backend/tests/unit/test_route_auth_coverage.py
@@ -31,10 +31,10 @@ from backend.app.main import app
# Substring patterns identifying auth-bearing callable qualnames in the
# resolved Depends() tree. Inner functions returned by factories carry
# the outer factory's name in their qualname (e.g.
-# ``require_permission..permission_checker``), so a substring
+# ``require_permission_if_auth_enabled..permission_checker``), so a substring
# check is enough; we don't have to enumerate the inner names.
_AUTH_QUALNAME_PATTERNS: tuple[str, ...] = (
- "require_", # require_permission, require_permission_if_auth_enabled, require_role, require_admin_*, require_auth_*, require_any_*, require_ownership_*, require_camera_stream_token_*, require_energy_cost_update
+ "require_", # require_permission_if_auth_enabled, require_admin_*, require_auth_*, require_any_*, require_ownership_*, require_camera_stream_token_*, require_energy_cost_update
"cloud_caller", # cloud.py route-level dep
"_cloud_api_key_gate", # cloud.py router-level dep
"resolve_api_key_cloud_owner", # used by slicer routes that need the API key's owner
diff --git a/frontend/knip.jsonc b/frontend/knip.jsonc
new file mode 100644
index 000000000..34cdeb275
--- /dev/null
+++ b/frontend/knip.jsonc
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://unpkg.com/knip@6/schema.json",
+ // The browser loads the service worker by URL (public/sw-register.js, which
+ // index.html includes), so no import leads to it.
+ "entry": ["public/sw.js"],
+ "ignore": [
+ // Tools run by hand (node scripts/...), not part of the app.
+ "scripts/**",
+ // Types the sibling vendor .js; TypeScript reads it for that import.
+ "src/lib/vendor/toolpathRenderer.d.ts"
+ ],
+ // Keeps browserslist's baseline data current; nothing imports it.
+ "ignoreDependencies": ["baseline-browser-mapping"],
+ // Unused exports and types are left out: most are only used in their own file.
+ "include": ["files", "dependencies", "devDependencies", "unlisted", "unresolved", "binaries"]
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6e419e348..5d54a0e87 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,7 +11,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
- "@floating-ui/dom": "^1.7.5",
"@tanstack/react-query": "^5.90.11",
"@tiptap/extension-color": "^3.11.1",
"@tiptap/extension-image": "^3.11.1",
@@ -61,13 +60,13 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^4.1.11",
- "autoprefixer": "^10.4.22",
"baseline-browser-mapping": "^2.9.19",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^25.0.0",
+ "knip": "^6.36.0",
"msw": "^2.6.0",
"postcss": "^8.5.6",
"remark-parse": "^11.0.0",
@@ -772,6 +771,7 @@
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz",
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
@@ -781,6 +781,7 @@
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz",
"integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.4",
"@floating-ui/utils": "^0.2.10"
@@ -790,7 +791,8 @@
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
@@ -1128,9 +1130,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1151,9 +1150,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1174,9 +1170,6 @@
"cpu": [
"riscv64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1197,9 +1190,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1220,9 +1210,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1277,21 +1264,24 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
- "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz",
+ "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==",
"dev": true,
"optional": true,
"dependencies": {
- "@tybys/wasm-util": "^0.10.2"
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=23.5.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1"
+ "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4",
+ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
"node_modules/@open-draft/deferred-promise": {
@@ -1319,6 +1309,310 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@oxc-parser/binding-android-arm-eabi": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.148.0.tgz",
+ "integrity": "sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-android-arm64": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.148.0.tgz",
+ "integrity": "sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-darwin-arm64": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.148.0.tgz",
+ "integrity": "sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-darwin-x64": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.148.0.tgz",
+ "integrity": "sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-freebsd-x64": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.148.0.tgz",
+ "integrity": "sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.148.0.tgz",
+ "integrity": "sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.148.0.tgz",
+ "integrity": "sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm64-gnu": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.148.0.tgz",
+ "integrity": "sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm64-musl": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.148.0.tgz",
+ "integrity": "sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.148.0.tgz",
+ "integrity": "sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.148.0.tgz",
+ "integrity": "sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-riscv64-musl": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.148.0.tgz",
+ "integrity": "sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-s390x-gnu": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.148.0.tgz",
+ "integrity": "sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-x64-gnu": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.148.0.tgz",
+ "integrity": "sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-x64-musl": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.148.0.tgz",
+ "integrity": "sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-openharmony-arm64": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.148.0.tgz",
+ "integrity": "sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-arm64-msvc": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.148.0.tgz",
+ "integrity": "sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-ia32-msvc": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.148.0.tgz",
+ "integrity": "sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-x64-msvc": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.148.0.tgz",
+ "integrity": "sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
"node_modules/@oxc-project/types": {
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
@@ -1328,6 +1622,289 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@oxc-resolver/binding-android-arm-eabi": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz",
+ "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-android-arm64": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz",
+ "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-darwin-arm64": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz",
+ "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-darwin-x64": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz",
+ "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-freebsd-x64": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz",
+ "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz",
+ "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz",
+ "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz",
+ "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm64-musl": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz",
+ "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz",
+ "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz",
+ "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz",
+ "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz",
+ "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-x64-gnu": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz",
+ "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-x64-musl": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz",
+ "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-openharmony-arm64": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz",
+ "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz",
+ "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.2",
+ "@emnapi/runtime": "1.11.2",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
+ "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz",
+ "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-win32-x64-msvc": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz",
+ "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -2528,9 +3105,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
- "version": "0.10.2",
- "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
- "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+ "version": "0.10.4",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
+ "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
"dev": true,
"optional": true,
"dependencies": {
@@ -3354,43 +3931,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/autoprefixer": {
- "version": "10.4.24",
- "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
- "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "tidelift",
- "url": "https://tidelift.com/funding/github/npm/autoprefixer"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "browserslist": "^4.28.1",
- "caniuse-lite": "^1.0.30001766",
- "fraction.js": "^5.3.4",
- "picocolors": "^1.1.1",
- "postcss-value-parser": "^4.2.0"
- },
- "bin": {
- "autoprefixer": "bin/autoprefixer"
- },
- "engines": {
- "node": "^10 || ^12 || >=14"
- },
- "peerDependencies": {
- "postcss": "^8.1.0"
- }
- },
"node_modules/bail": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -4416,6 +4956,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fd-package-json": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
+ "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
+ "dev": true,
+ "dependencies": {
+ "walk-up-path": "^4.0.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -4506,18 +5055,20 @@
"node": ">= 6"
}
},
- "node_modules/fraction.js": {
- "version": "5.3.4",
- "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
- "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "node_modules/formatly": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.7.1.tgz",
+ "integrity": "sha512-ESuCEJE0SGgabl9q+zABI8LhJDmF+8nhpuqDzuM48HfJ9QTD5o6PqFJDFZY7w6f8xsh5lixuj5nJsQNvYOWvKQ==",
"dev": true,
- "license": "MIT",
- "engines": {
- "node": "*"
+ "dependencies": {
+ "fd-package-json": "^2.0.0",
+ "package-manager-detector": "^1.8.0"
},
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/rawify"
+ "bin": {
+ "formatly": "bin/index.mjs"
+ },
+ "engines": {
+ "node": ">=18.3.0"
}
},
"node_modules/fsevents": {
@@ -4603,6 +5154,18 @@
"node": ">= 0.4"
}
},
+ "node_modules/get-tsconfig": {
+ "version": "4.14.3",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz",
+ "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==",
+ "dev": true,
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+ }
+ },
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -5124,11 +5687,10 @@
}
},
"node_modules/jiti": {
- "version": "2.6.1",
- "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
- "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
- "license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -5270,6 +5832,56 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/knip": {
+ "version": "6.36.0",
+ "resolved": "https://registry.npmjs.org/knip/-/knip-6.36.0.tgz",
+ "integrity": "sha512-DWETufDYX4UdaLeCK9gI5rfROanxVxbrsyEPzHFIrrn1H+W04f3XdQ6HPWMnA7O2u2VOKOLWt+EV23K/kb5jlA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/webpro"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/knip"
+ }
+ ],
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "formatly": "^0.7.0",
+ "get-tsconfig": "4.14.3",
+ "jiti": "^2.7.0",
+ "oxc-parser": "^0.148.0",
+ "oxc-resolver": "11.24.2",
+ "picomatch": "^4.0.7",
+ "smol-toml": "^1.8.0",
+ "strip-json-comments": "5.0.3",
+ "tinyglobby": "^0.2.17",
+ "unbash": "^4.0.11",
+ "yaml": "^2.9.0",
+ "zod": "^4.4.3"
+ },
+ "bin": {
+ "knip": "bin/knip.js",
+ "knip-bun": "bin/knip-bun.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/knip/node_modules/strip-json-comments": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
+ "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -6624,6 +7236,81 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/oxc-parser": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.148.0.tgz",
+ "integrity": "sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==",
+ "dev": true,
+ "dependencies": {
+ "@oxc-project/types": "^0.148.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ },
+ "optionalDependencies": {
+ "@oxc-parser/binding-android-arm-eabi": "0.148.0",
+ "@oxc-parser/binding-android-arm64": "0.148.0",
+ "@oxc-parser/binding-darwin-arm64": "0.148.0",
+ "@oxc-parser/binding-darwin-x64": "0.148.0",
+ "@oxc-parser/binding-freebsd-x64": "0.148.0",
+ "@oxc-parser/binding-linux-arm-gnueabihf": "0.148.0",
+ "@oxc-parser/binding-linux-arm-musleabihf": "0.148.0",
+ "@oxc-parser/binding-linux-arm64-gnu": "0.148.0",
+ "@oxc-parser/binding-linux-arm64-musl": "0.148.0",
+ "@oxc-parser/binding-linux-ppc64-gnu": "0.148.0",
+ "@oxc-parser/binding-linux-riscv64-gnu": "0.148.0",
+ "@oxc-parser/binding-linux-riscv64-musl": "0.148.0",
+ "@oxc-parser/binding-linux-s390x-gnu": "0.148.0",
+ "@oxc-parser/binding-linux-x64-gnu": "0.148.0",
+ "@oxc-parser/binding-linux-x64-musl": "0.148.0",
+ "@oxc-parser/binding-openharmony-arm64": "0.148.0",
+ "@oxc-parser/binding-win32-arm64-msvc": "0.148.0",
+ "@oxc-parser/binding-win32-ia32-msvc": "0.148.0",
+ "@oxc-parser/binding-win32-x64-msvc": "0.148.0"
+ }
+ },
+ "node_modules/oxc-parser/node_modules/@oxc-project/types": {
+ "version": "0.148.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
+ "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/oxc-resolver": {
+ "version": "11.24.2",
+ "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz",
+ "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxc-resolver/binding-android-arm-eabi": "11.24.2",
+ "@oxc-resolver/binding-android-arm64": "11.24.2",
+ "@oxc-resolver/binding-darwin-arm64": "11.24.2",
+ "@oxc-resolver/binding-darwin-x64": "11.24.2",
+ "@oxc-resolver/binding-freebsd-x64": "11.24.2",
+ "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2",
+ "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2",
+ "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2",
+ "@oxc-resolver/binding-linux-arm64-musl": "11.24.2",
+ "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2",
+ "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2",
+ "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2",
+ "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2",
+ "@oxc-resolver/binding-linux-x64-gnu": "11.24.2",
+ "@oxc-resolver/binding-linux-x64-musl": "11.24.2",
+ "@oxc-resolver/binding-openharmony-arm64": "11.24.2",
+ "@oxc-resolver/binding-wasm32-wasi": "11.24.2",
+ "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2",
+ "@oxc-resolver/binding-win32-x64-msvc": "11.24.2"
+ }
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -6656,6 +7343,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/package-manager-detector": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz",
+ "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==",
+ "dev": true
+ },
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
@@ -6771,11 +7464,10 @@
"license": "ISC"
},
"node_modules/picomatch": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
- "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
- "license": "MIT",
"engines": {
"node": ">=12"
},
@@ -6811,13 +7503,6 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/postcss-value-parser": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
- "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -7321,6 +8006,15 @@
"node": ">=4"
}
},
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
"node_modules/rettime": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz",
@@ -7474,6 +8168,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/smol-toml": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz",
+ "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyyynthia"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7847,6 +8553,15 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
+ "node_modules/unbash": {
+ "version": "4.0.12",
+ "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.12.tgz",
+ "integrity": "sha512-PnKvRX+Fbxl/usVlTr/31tXIOVAH+8OdC9qaxUQcvurwEnoc3Vk/ipi2Ce8IjMxzK9byfSGdsUBjqqJGc1o5lw==",
+ "dev": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
@@ -8491,6 +9206,15 @@
"node": ">=18"
}
},
+ "node_modules/walk-up-path": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
+ "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
+ "dev": true,
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
@@ -8698,6 +9422,21 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true
},
+ "node_modules/yaml": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
+ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
+ "dev": true,
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -8789,11 +9528,10 @@
}
},
"node_modules/zod": {
- "version": "4.3.6",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
- "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "version": "4.6.5",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
+ "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"dev": true,
- "license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/frontend/package.json b/frontend/package.json
index 3b9d857ee..db41ef529 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,13 +14,13 @@
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"check:i18n": "node scripts/check-i18n-parity.mjs",
+ "check:knip": "knip",
"check:baseline": "node scripts/check-browser-baseline.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
- "@floating-ui/dom": "^1.7.5",
"@tanstack/react-query": "^5.90.11",
"@tiptap/extension-color": "^3.11.1",
"@tiptap/extension-image": "^3.11.1",
@@ -77,13 +77,13 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@vitest/coverage-v8": "^4.1.11",
- "autoprefixer": "^10.4.22",
"baseline-browser-mapping": "^2.9.19",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^25.0.0",
+ "knip": "^6.36.0",
"msw": "^2.6.0",
"postcss": "^8.5.6",
"remark-parse": "^11.0.0",
diff --git a/frontend/src/App.css b/frontend/src/App.css
deleted file mode 100644
index b9d355df2..000000000
--- a/frontend/src/App.css
+++ /dev/null
@@ -1,42 +0,0 @@
-#root {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- text-align: center;
-}
-
-.logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- transition: filter 300ms;
-}
-.logo:hover {
- filter: drop-shadow(0 0 2em #646cffaa);
-}
-.logo.react:hover {
- filter: drop-shadow(0 0 2em #61dafbaa);
-}
-
-@keyframes logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
-}
-
-@media (prefers-reduced-motion: no-preference) {
- a:nth-of-type(2) .logo {
- animation: logo-spin infinite 20s linear;
- }
-}
-
-.card {
- padding: 2em;
-}
-
-.read-the-docs {
- color: #888;
-}
diff --git a/frontend/src/components/icons/WifiSignal.tsx b/frontend/src/components/icons/WifiSignal.tsx
deleted file mode 100644
index 825bceb2a..000000000
--- a/frontend/src/components/icons/WifiSignal.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-interface WifiSignalProps {
- signal: number | null | undefined; // dBm value
- className?: string;
-}
-
-/**
- * WiFi signal icon with 4 bars that fill based on signal strength.
- * - 4 bars: >= -50 dBm (excellent)
- * - 3 bars: >= -60 dBm (good)
- * - 2 bars: >= -70 dBm (fair)
- * - 1 bar: < -70 dBm (weak)
- * - 0 bars: no signal data
- */
-export function WifiSignal({ signal, className = "w-4 h-4" }: WifiSignalProps) {
- let bars = 0;
- if (signal != null) {
- if (signal >= -50) bars = 4;
- else if (signal >= -60) bars = 3;
- else if (signal >= -70) bars = 2;
- else bars = 1;
- }
-
- const activeColor = "#00ae42"; // bambu-green
- const inactiveColor = "#4a4a4a"; // dark gray
-
- return (
-
- );
-}
diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx
deleted file mode 100644
index 8bee17bda..000000000
--- a/frontend/src/pages/UsersPage.tsx
+++ /dev/null
@@ -1,810 +0,0 @@
-import { useState, useEffect, useMemo } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useTranslation } from 'react-i18next';
-import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft, RotateCcw } from 'lucide-react';
-import { api } from '../api/client';
-import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
-import { useAuth } from '../contexts/AuthContext';
-import { useToast } from '../contexts/ToastContext';
-import { Button } from '../components/Button';
-import { Card, CardContent, CardHeader } from '../components/Card';
-import { ConfirmModal } from '../components/ConfirmModal';
-import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
-import { LdapUserPicker } from '../components/LdapUserPicker';
-
-interface FormData extends UserCreate {
- group_ids: number[];
- confirmPassword: string;
- email?: string;
-}
-
-export function UsersPage() {
- const navigate = useNavigate();
- const { t } = useTranslation();
- const { user: currentUser, hasPermission } = useAuth();
- const { showToast } = useToast();
- const queryClient = useQueryClient();
- const [showCreateModal, setShowCreateModal] = useState(false);
- // Basic-mode (non-advanced-auth) modal: track which tab is active so the
- // LDAP picker can replace the local form when LDAP is enabled.
- const [basicCreateTab, setBasicCreateTab] = useState<'local' | 'ldap'>('local');
- const [showEditModal, setShowEditModal] = useState(false);
- const [editingUserId, setEditingUserId] = useState(null);
- const [deleteUserId, setDeleteUserId] = useState(null);
- const [formData, setFormData] = useState({
- username: '',
- password: '',
- email: '',
- confirmPassword: '',
- role: 'user',
- group_ids: [],
- });
-
- // Check if advanced auth is enabled
- const { data: advancedAuthStatus = { advanced_auth_enabled: false, smtp_configured: false } } = useQuery({
- queryKey: ['advancedAuthStatus'],
- queryFn: () => api.getAdvancedAuthStatus(),
- });
-
- // LDAP status — drives whether the LDAP tab is rendered in the create modal.
- const { data: ldapStatus = { ldap_enabled: false, ldap_configured: false } } = useQuery({
- queryKey: ['ldapStatus'],
- queryFn: () => api.getLDAPStatus(),
- });
-
- // Close modal on Escape key
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- if (showCreateModal) {
- setShowCreateModal(false);
- setBasicCreateTab('local');
- setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
- }
- if (showEditModal) {
- setShowEditModal(false);
- setEditingUserId(null);
- setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
- }
- }
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [showCreateModal, showEditModal]);
-
- const { data: users = [], isLoading } = useQuery({
- queryKey: ['users'],
- queryFn: () => api.getUsers(),
- enabled: hasPermission('users:read'),
- });
-
- const { data: groups = [] } = useQuery({
- queryKey: ['groups'],
- queryFn: () => api.getGroups(),
- enabled: hasPermission('groups:read'),
- });
-
- const createMutation = useMutation({
- mutationFn: (data: UserCreate) => api.createUser(data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['users'] });
- queryClient.invalidateQueries({ queryKey: ['groups'] });
- setShowCreateModal(false);
- setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
- showToast(t('users.toast.created'));
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
-
- const updateMutation = useMutation({
- mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['users'] });
- queryClient.invalidateQueries({ queryKey: ['groups'] });
- setShowEditModal(false);
- setEditingUserId(null);
- setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
- showToast(t('users.toast.updated'));
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
-
- const deleteMutation = useMutation({
- mutationFn: (id: number) => api.deleteUser(id),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['users'] });
- showToast(t('users.toast.deleted'));
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
-
- const resetPasswordMutation = useMutation({
- mutationFn: (userId: number) => api.resetUserPassword({ user_id: userId }),
- onSuccess: (data) => {
- showToast(data.message, 'success');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
-
- // Validation for create user button
- const isCreateButtonDisabled = useMemo(() => {
- if (createMutation.isPending || !formData.username) {
- return true;
- }
- if (advancedAuthStatus?.advanced_auth_enabled) {
- // When advanced auth is enabled, require email (password is auto-generated)
- return !formData.email;
- }
- // When advanced auth is disabled, require valid password
- return !formData.password || formData.password !== formData.confirmPassword || formData.password.length < 6;
- }, [
- createMutation.isPending,
- formData.username,
- formData.email,
- formData.password,
- formData.confirmPassword,
- advancedAuthStatus?.advanced_auth_enabled
- ]);
-
- const handleCreate = () => {
- // Use the status from the query hook
- const advancedAuthEnabled = advancedAuthStatus?.advanced_auth_enabled || false;
-
- if (!formData.username) {
- const errorMsg = t('users.toast.fillRequired');
- showToast(errorMsg, 'error');
- if (advancedAuthEnabled) {
- console.error('[Advanced Auth] Create user failed: Username is required');
- }
- return;
- }
-
- // Email is required when advanced auth is enabled
- if (advancedAuthEnabled && !formData.email) {
- const errorMsg = 'Email is required when advanced authentication is enabled';
- showToast(errorMsg, 'error');
- console.error('[Advanced Auth] Create user failed: Email is required when advanced authentication is enabled');
- return;
- }
-
- // Password validation only when advanced auth is disabled
- if (!advancedAuthEnabled) {
- if (!formData.password) {
- showToast(t('users.toast.fillRequired'), 'error');
- return;
- }
- if (formData.password !== formData.confirmPassword) {
- showToast(t('users.toast.passwordsDoNotMatch'), 'error');
- return;
- }
- if (formData.password.length < 6) {
- showToast(t('users.toast.passwordTooShort'), 'error');
- return;
- }
- }
-
- createMutation.mutate({
- username: formData.username,
- password: advancedAuthEnabled ? undefined : formData.password,
- email: formData.email || undefined,
- role: formData.role,
- group_ids: formData.group_ids.length > 0 ? formData.group_ids : undefined,
- });
- };
-
- const handleUpdate = (id: number) => {
- // Validate password confirmation if a new password is being set
- if (formData.password) {
- if (formData.password !== formData.confirmPassword) {
- showToast(t('users.toast.passwordsDoNotMatch'), 'error');
- return;
- }
- if (formData.password.length < 6) {
- showToast(t('users.toast.passwordTooShort'), 'error');
- return;
- }
- }
- const updateData: UserUpdate = {
- username: formData.username || undefined,
- password: formData.password || undefined,
- email: formData.email || undefined,
- role: formData.role,
- group_ids: formData.group_ids,
- };
- // Remove password if empty
- if (!updateData.password) {
- delete updateData.password;
- }
- // Remove email if empty
- if (!updateData.email) {
- delete updateData.email;
- }
- updateMutation.mutate({ id, data: updateData });
- };
-
- const handleDelete = (id: number) => {
- setDeleteUserId(id);
- };
-
- const startEdit = (user: UserResponse) => {
- setEditingUserId(user.id);
- setFormData({
- username: user.username,
- password: '',
- email: user.email || '',
- confirmPassword: '',
- role: user.role,
- group_ids: user.groups?.map(g => g.id) || [],
- });
- setShowEditModal(true);
- };
-
- const closeEditModal = () => {
- setShowEditModal(false);
- setEditingUserId(null);
- setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
- };
-
- const toggleGroup = (groupId: number) => {
- setFormData(prev => ({
- ...prev,
- group_ids: prev.group_ids.includes(groupId)
- ? prev.group_ids.filter(id => id !== groupId)
- : [...prev.group_ids, groupId],
- }));
- };
-
- if (!hasPermission('users:read')) {
- return (
-
-
-
-
-
-
{t('users.noPermission')}
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
- {t('users.title')}
-
-
- {t('users.subtitle')}
-
-
-
-
-
-
- {isLoading ? (
-
-
-
- ) : (
-
-
-
-
-
- |
- {t('users.table.username')}
- |
-
- {t('users.table.groups')}
- |
-
- {t('users.table.status')}
- |
-
- {t('users.table.actions')}
- |
-
-
-
- {users.map((user) => (
-
- |
- {user.username}
- |
-
-
- {user.is_admin && (
-
- {t('users.admin')}
-
- )}
- {user.groups?.map(group => (
-
- {group.name}
-
- ))}
- {(!user.groups || user.groups.length === 0) && !user.is_admin && (
- {t('users.noGroups')}
- )}
-
- |
-
-
- {user.is_active ? t('users.active') : t('users.inactive')}
-
- |
-
-
-
- {user.id !== currentUser?.id && (
-
- )}
- {advancedAuthStatus?.advanced_auth_enabled && user.email && user.id !== currentUser?.id && (
-
- )}
-
- |
-
- ))}
-
-
-
-
- )}
-
- {/* Create User Modal */}
- {showCreateModal && !advancedAuthStatus?.advanced_auth_enabled && (
-
{
- setShowCreateModal(false);
- setBasicCreateTab('local');
- setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
- }}
- >
-
e.stopPropagation()}
- >
-
-
-
-
-
{t('users.modal.createUser')}
-
-
-
-
-
- {ldapStatus?.ldap_enabled && (
-
-
-
-
- )}
-
- {basicCreateTab === 'ldap' && ldapStatus?.ldap_enabled ? (
- <>
- {
- setShowCreateModal(false);
- setBasicCreateTab('local');
- setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
- showToast(t('users.toast.ldapProvisioned', { username: user.username }));
- }}
- />
-
-
-
- >
- ) : (
- <>
-
-
-
- setFormData({ ...formData, username: e.target.value })}
- className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
- placeholder={t('users.form.usernamePlaceholder')}
- autoComplete="username"
- />
-
-
-
- setFormData({ ...formData, password: e.target.value })}
- className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
- placeholder={t('users.form.passwordPlaceholder')}
- autoComplete="new-password"
- minLength={6}
- />
-
-
-
-
setFormData({ ...formData, confirmPassword: e.target.value })}
- className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
- formData.confirmPassword && formData.password !== formData.confirmPassword
- ? 'border-red-500'
- : 'border-bambu-dark-tertiary'
- }`}
- placeholder={t('users.form.confirmPasswordPlaceholder')}
- autoComplete="new-password"
- minLength={6}
- />
- {formData.confirmPassword && formData.password !== formData.confirmPassword && (
-
{t('users.toast.passwordsDoNotMatch')}
- )}
-
-
-
-
- {groups.map(group => (
-
- ))}
- {groups.length === 0 && (
-
{t('users.noGroupsAvailable')}
- )}
-
-
-
-
-
-
-
- >
- )}
-
-
-
- )}
-
- {/* Create User Modal - Advanced Authentication */}
- {showCreateModal && advancedAuthStatus?.advanced_auth_enabled && (
-
{
- setShowCreateModal(false);
- setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
- }}
- onCreate={handleCreate}
- isCreating={createMutation.isPending}
- isCreateButtonDisabled={isCreateButtonDisabled}
- ldapEnabled={ldapStatus?.ldap_enabled}
- onLdapProvisioned={(user) => {
- setShowCreateModal(false);
- setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
- showToast(t('users.toast.ldapProvisioned', { username: user.username }));
- }}
- />
- )}
-
- {/* Edit User Modal */}
- {showEditModal && editingUserId !== null && (
-
-
e.stopPropagation()}
- >
-
-
-
-
-
{t('users.modal.editUser')}
-
-
-
-
-
-
-
-
- setFormData({ ...formData, username: e.target.value })}
- className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
- placeholder={t('users.form.usernamePlaceholder')}
- autoComplete="username"
- />
-
-
-
- setFormData({ ...formData, email: e.target.value })}
- className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
- placeholder={t('users.form.emailPlaceholder') || 'user@example.com'}
- />
-
-
-
- setFormData({ ...formData, password: e.target.value, confirmPassword: '' })}
- className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
- placeholder={t('users.form.newPasswordPlaceholder')}
- autoComplete="new-password"
- minLength={6}
- />
-
- {formData.password && (
-
-
-
setFormData({ ...formData, confirmPassword: e.target.value })}
- className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
- formData.confirmPassword && formData.password !== formData.confirmPassword
- ? 'border-red-500'
- : 'border-bambu-dark-tertiary'
- }`}
- placeholder={t('users.form.confirmNewPasswordPlaceholder')}
- autoComplete="new-password"
- minLength={6}
- />
- {formData.confirmPassword && formData.password !== formData.confirmPassword && (
-
{t('users.toast.passwordsDoNotMatch')}
- )}
-
- )}
-
-
-
- {groups.map(group => (
-
- ))}
- {groups.length === 0 && (
-
{t('users.noGroupsAvailable')}
- )}
-
-
-
-
-
-
-
-
-
-
- )}
-
- {/* Delete Confirmation Modal */}
- {deleteUserId !== null && (
- {
- deleteMutation.mutate(deleteUserId);
- setDeleteUserId(null);
- }}
- onCancel={() => setDeleteUserId(null)}
- />
- )}
-
- );
-}
diff --git a/test_frontend.sh b/test_frontend.sh
index cee196a9a..1b4036f28 100755
--- a/test_frontend.sh
+++ b/test_frontend.sh
@@ -1,5 +1,5 @@
#!/bin/sh
-# Typecheck and lint run in parallel (both are read-only and independent),
+# Typecheck, lint and knip run in parallel (all read-only and independent),
# then the tests. Exits non-zero if any step fails, so a type or lint error
# can't scroll past unnoticed behind a green test run.
@@ -9,21 +9,25 @@ npm run typecheck > /tmp/bambuddy-typecheck.$$ 2>&1 &
typecheck_pid=$!
npm run lint > /tmp/bambuddy-lint.$$ 2>&1 &
lint_pid=$!
+npm run check:knip > /tmp/bambuddy-knip.$$ 2>&1 &
+knip_pid=$!
wait $typecheck_pid
typecheck_rc=$?
wait $lint_pid
lint_rc=$?
+wait $knip_pid
+knip_rc=$?
-cat /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$
-rm -f /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$
+cat /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$ /tmp/bambuddy-knip.$$
+rm -f /tmp/bambuddy-typecheck.$$ /tmp/bambuddy-lint.$$ /tmp/bambuddy-knip.$$
npm run test:run
test_rc=$?
cd ..
-if [ $typecheck_rc -ne 0 ] || [ $lint_rc -ne 0 ] || [ $test_rc -ne 0 ]; then
- echo "FAILED: typecheck=$typecheck_rc lint=$lint_rc tests=$test_rc"
+if [ $typecheck_rc -ne 0 ] || [ $lint_rc -ne 0 ] || [ $knip_rc -ne 0 ] || [ $test_rc -ne 0 ]; then
+ echo "FAILED: typecheck=$typecheck_rc lint=$lint_rc knip=$knip_rc tests=$test_rc"
exit 1
fi