From b5cd369f51cf1c7e702a314ec51b92b3036a87af Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 11 Oct 2026 11:17:54 +0200 Subject: [PATCH] chore: remove dead code and fail CI on unused frontend files vulture and knip found code nothing reaches; each hit was checked for use by name (getattr dispatch, string references, lazy imports) and only the dead ones were removed, about 2,700 lines: - backend: the unused auth factories (RequireAdmin, RequirePermission, require_permission, require_role), the unused backend i18n package, printer fan/axis/motor/timelapse/liveview helpers no route uses, the amqtt-based virtual printer MQTT server and the FTP TLS proxy that nothing starts, the emailed-password reset functions (reset sends a link), the unreachable "password_reset_link" template branch, force_immediate (every notification has been sent immediately and also queued for the digest for a long time) and an unused parameter - frontend: UsersPage, WifiSignal, App.css, and the unused @floating-ui/dom and autoprefixer packages knip now runs in CI (frontend lint job), in test_frontend.sh and as npm run check:knip, checking unused files and dependencies; knip.jsonc says why the few exceptions are there. The basedpyright baseline shrinks by 10. --- .basedpyright/baseline.json | 80 -- .github/workflows/ci.yml | 4 + CONTRIBUTING.md | 16 +- backend/app/api/routes/settings.py | 4 +- backend/app/core/auth.py | 102 +- backend/app/core/printer_scope.py | 8 - backend/app/i18n/__init__.py | 125 --- backend/app/services/bambu_mqtt.py | 127 --- backend/app/services/camera.py | 6 - backend/app/services/email_service.py | 158 +-- backend/app/services/external_camera.py | 13 - backend/app/services/filament_deficit.py | 5 - backend/app/services/label_renderer.py | 5 - backend/app/services/location_service.py | 15 - backend/app/services/network_utils.py | 13 - backend/app/services/notification_service.py | 31 +- backend/app/services/plate_detection.py | 11 - backend/app/services/spoolman.py | 37 - .../services/virtual_printer/mqtt_server.py | 142 +-- .../app/services/virtual_printer/tcp_proxy.py | 669 ------------- .../tests/unit/services/test_email_service.py | 41 - .../services/test_notification_service.py | 37 - .../unit/services/test_p2s_accessory_fans.py | 8 +- .../test_postgres_restore_drop_cascade.py | 12 +- .../tests/unit/test_restore_schema_compat.py | 4 +- .../tests/unit/test_route_auth_coverage.py | 4 +- frontend/knip.jsonc | 16 + frontend/package-lock.json | 924 ++++++++++++++++-- frontend/package.json | 4 +- frontend/src/App.css | 42 - frontend/src/components/icons/WifiSignal.tsx | 64 -- frontend/src/pages/UsersPage.tsx | 810 --------------- test_frontend.sh | 14 +- 33 files changed, 906 insertions(+), 2645 deletions(-) delete mode 100644 backend/app/i18n/__init__.py create mode 100644 frontend/knip.jsonc delete mode 100644 frontend/src/App.css delete mode 100644 frontend/src/components/icons/WifiSignal.tsx delete mode 100644 frontend/src/pages/UsersPage.tsx 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}

-
- -
- Login Now -
- -
-

- ⚠️ 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}
- -
- Login Now -
- -
-

- ⚠️ 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 ( - - {/* Dot at bottom */} - = 1 ? activeColor : inactiveColor} - stroke={bars >= 1 ? activeColor : inactiveColor} - /> - {/* First arc (smallest) */} - = 2 ? activeColor : inactiveColor} - fill="none" - /> - {/* Second arc */} - = 3 ? activeColor : inactiveColor} - fill="none" - /> - {/* Third arc (largest) */} - = 4 ? activeColor : inactiveColor} - fill="none" - /> - - ); -} 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 ? ( -
- -
- ) : ( - -
- - - - - - - - - - - {users.map((user) => ( - - - - - - - ))} - -
- {t('users.table.username')} - - {t('users.table.groups')} - - {t('users.table.status')} - - {t('users.table.actions')} -
- {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