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.
This commit is contained in:
maziggy
2026-10-11 11:17:54 +02:00
parent 4588e7632c
commit b5cd369f51
33 changed files with 906 additions and 2645 deletions
-80
View File
@@ -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": {
+4
View File
@@ -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
+13 -3
View File
@@ -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
+2 -2
View File
@@ -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)
+1 -101
View File
@@ -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))
-8
View File
@@ -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
-125
View File
@@ -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)
-127
View File
@@ -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.
-6
View File
@@ -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.
+5 -153
View File
@@ -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"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 20px; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">Password Reset</h1>
</div>
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
<p style="font-size: 16px;">Your BamBuddy password has been reset.</p>
<div style="background: white; padding: 20px; border-radius: 4px; margin: 20px 0; border-left: 4px solid #667eea;">
<p style="margin: 0 0 10px 0;"><strong>Username:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{username}</code></p>
<p style="margin: 0;"><strong>New Password:</strong> <code style="background: #f0f0f0; padding: 2px 6px; border-radius: 3px;">{password}</code></p>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
<p style="margin: 0; font-size: 14px; color: #856404;">
<strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
</p>
</div>
<p style="font-size: 14px; color: #666; border-top: 1px solid #ddd; padding-top: 20px; margin-top: 20px;">
<strong>Security Note:</strong> For security reasons, please change your password after logging in.
</p>
<p style="font-size: 14px; color: #999; margin-top: 30px;">
Best regards,<br>
BamBuddy Team
</p>
</div>
</body>
</html>
"""
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 <br> tags
escaped_text_body = html.escape(text_body).replace("\n", "<br>\n")
html_body = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background-color: #667eea; padding: 30px; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">{html.escape(subject)}</h1>
</div>
<div style="background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; border: 1px solid #ddd; border-top: none;">
<div style="font-size: 16px;">{escaped_text_body}</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{login_url}" style="display: inline-block; background-color: #667eea; color: #ffffff; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">Login Now</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;">
<p style="margin: 0; font-size: 14px; color: #856404;">
<strong>⚠️ Security Alert:</strong> If you did not request this password reset, please contact your administrator immediately.
</p>
</div>
</div>
</body>
</html>
"""
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,
-13
View File
@@ -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).
-5
View File
@@ -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],
-5
View File
@@ -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.
-15
View File
@@ -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()}
-13
View File
@@ -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]
+9 -22
View File
@@ -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,
)
-11
View File
@@ -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
-37
View File
@@ -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]:
@@ -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__(
@@ -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 <control_IP>:<port>, 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."""
@@ -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!<br>" in html_body
assert "Your password is: testpass123<br>" 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 <br> 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 <br> tags
assert "<br>" 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."""
@@ -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):
@@ -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):
@@ -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"
@@ -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]}"
@@ -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.<locals>.permission_checker``), so a substring
# ``require_permission_if_auth_enabled.<locals>.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
+16
View File
@@ -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"]
}
+831 -93
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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",
-42
View File
@@ -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;
}
@@ -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 (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{/* Dot at bottom */}
<circle
cx="12"
cy="20"
r="1"
fill={bars >= 1 ? activeColor : inactiveColor}
stroke={bars >= 1 ? activeColor : inactiveColor}
/>
{/* First arc (smallest) */}
<path
d="M8.5 16.5a5 5 0 0 1 7 0"
stroke={bars >= 2 ? activeColor : inactiveColor}
fill="none"
/>
{/* Second arc */}
<path
d="M5 13a10 10 0 0 1 14 0"
stroke={bars >= 3 ? activeColor : inactiveColor}
fill="none"
/>
{/* Third arc (largest) */}
<path
d="M1.5 9.5a15 15 0 0 1 21 0"
stroke={bars >= 4 ? activeColor : inactiveColor}
fill="none"
/>
</svg>
);
}
-810
View File
@@ -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<number | null>(null);
const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
const [formData, setFormData] = useState<FormData>({
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 (
<div className="p-6">
<Card>
<CardContent className="py-6">
<div className="flex items-center gap-3 text-red-700 dark:text-red-400">
<Shield className="w-5 h-5" />
<p className="text-white">{t('users.noPermission')}</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/settings?tab=users')}
className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
title={t('users.backToSettings')}
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<UsersIcon className="w-6 h-6 text-bambu-green" />
{t('users.title')}
</h1>
<p className="text-sm text-bambu-gray mt-1">
{t('users.subtitle')}
</p>
</div>
</div>
<Button
onClick={() => {
setShowCreateModal(true);
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
<Plus className="w-4 h-4" />
{t('users.createUser')}
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : (
<Card>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-bambu-dark-tertiary">
<thead>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
{t('users.table.username')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
{t('users.table.groups')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
{t('users.table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
{t('users.table.actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-bambu-dark-tertiary">
{users.map((user) => (
<tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
{user.username}
</td>
<td className="px-6 py-4 text-sm">
<div className="flex flex-wrap gap-1">
{user.is_admin && (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300">
{t('users.admin')}
</span>
)}
{user.groups?.map(group => (
<span
key={group.id}
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
group.name === 'Administrators'
? 'bg-purple-100 dark:bg-purple-500/20 text-purple-700 dark:text-purple-300'
: group.name === 'Operators'
? 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300'
: group.name === 'Viewers'
? 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300'
: 'bg-gray-500/20 text-gray-300'
}`}
>
{group.name}
</span>
))}
{(!user.groups || user.groups.length === 0) && !user.is_admin && (
<span className="text-bambu-gray">{t('users.noGroups')}</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
user.is_active
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
}`}>
{user.is_active ? t('users.active') : t('users.inactive')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => startEdit(user)}
>
<Edit2 className="w-4 h-4" />
{t('users.edit')}
</Button>
{user.id !== currentUser?.id && (
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(user.id)}
>
<Trash2 className="w-4 h-4" />
{t('users.delete')}
</Button>
)}
{advancedAuthStatus?.advanced_auth_enabled && user.email && user.id !== currentUser?.id && (
<Button
size="sm"
variant="ghost"
onClick={() => resetPasswordMutation.mutate(user.id)}
disabled={resetPasswordMutation.isPending}
>
<RotateCcw className="w-4 h-4" />
{t('users.form.resetPassword') || 'Reset Password'}
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* Create User Modal */}
{showCreateModal && !advancedAuthStatus?.advanced_auth_enabled && (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={() => {
setShowCreateModal(false);
setBasicCreateTab('local');
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
<Card
className="w-full max-w-md"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UsersIcon className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('users.modal.createUser')}</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowCreateModal(false);
setBasicCreateTab('local');
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
<X className="w-5 h-5" />
</Button>
</div>
</CardHeader>
<CardContent>
{ldapStatus?.ldap_enabled && (
<div
className="mb-4 flex items-center gap-1 p-1 bg-bambu-dark-secondary rounded-lg"
role="tablist"
aria-label={t('users.modal.tabsAriaLabel')}
>
<button
type="button"
role="tab"
aria-selected={basicCreateTab === 'local'}
onClick={() => setBasicCreateTab('local')}
className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
basicCreateTab === 'local'
? 'bg-bambu-green/15 text-bambu-green'
: 'text-bambu-gray hover:text-white'
}`}
>
{t('users.modal.localTab')}
</button>
<button
type="button"
role="tab"
aria-selected={basicCreateTab === 'ldap'}
onClick={() => setBasicCreateTab('ldap')}
className={`flex-1 px-3 py-2 text-sm rounded-md transition-colors ${
basicCreateTab === 'ldap'
? 'bg-bambu-green/15 text-bambu-green'
: 'text-bambu-gray hover:text-white'
}`}
>
{t('users.modal.ldapTab')}
</button>
</div>
)}
{basicCreateTab === 'ldap' && ldapStatus?.ldap_enabled ? (
<>
<LdapUserPicker
onSuccess={(user) => {
setShowCreateModal(false);
setBasicCreateTab('local');
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
showToast(t('users.toast.ldapProvisioned', { username: user.username }));
}}
/>
<div className="mt-6 flex justify-end">
<Button
variant="secondary"
onClick={() => {
setShowCreateModal(false);
setBasicCreateTab('local');
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
{t('users.modal.cancel')}
</Button>
</div>
</>
) : (
<>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.username')}
</label>
<input
type="text"
value={formData.username}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.password')}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => 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}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.confirmPassword')}
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => 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 && (
<p className="text-red-700 dark:text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.groups')}
</label>
<div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
{groups.map(group => (
<label
key={group.id}
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
>
<input
type="checkbox"
checked={formData.group_ids.includes(group.id)}
onChange={() => toggleGroup(group.id)}
className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
/>
<span className="text-sm text-white">{group.name}</span>
{group.is_system && (
<span className="text-xs text-yellow-700 dark:text-yellow-400">({t('users.system')})</span>
)}
</label>
))}
{groups.length === 0 && (
<p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
)}
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => {
setShowCreateModal(false);
setFormData({ username: '', password: '', email: '', confirmPassword: '', role: 'user', group_ids: [] });
}}
>
{t('users.modal.cancel')}
</Button>
<Button
onClick={handleCreate}
disabled={isCreateButtonDisabled}
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('users.modal.creating')}
</>
) : (
<>
<Plus className="w-4 h-4" />
{t('users.modal.createUser')}
</>
)}
</Button>
</div>
</>
)}
</CardContent>
</Card>
</div>
)}
{/* Create User Modal - Advanced Authentication */}
{showCreateModal && advancedAuthStatus?.advanced_auth_enabled && (
<CreateUserAdvancedAuthModal
formData={formData}
setFormData={setFormData}
groups={groups}
onClose={() => {
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 && (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={closeEditModal}
>
<Card
className="w-full max-w-md"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Edit2 className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('users.modal.editUser')}</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={closeEditModal}
>
<X className="w-5 h-5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.username')}
</label>
<input
type="text"
value={formData.username}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.email') || 'Email'} <span className="text-bambu-gray font-normal">({t('users.form.optional') || 'optional'})</span>
</label>
<input
type="email"
value={formData.email}
onChange={(e) => 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'}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.password')} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep')})</span>
</label>
<input
type="password"
value={formData.password}
onChange={(e) => 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}
/>
</div>
{formData.password && (
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.confirmPassword')}
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => 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 && (
<p className="text-red-700 dark:text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('users.form.groups')}
</label>
<div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
{groups.map(group => (
<label
key={group.id}
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
>
<input
type="checkbox"
checked={formData.group_ids.includes(group.id)}
onChange={() => toggleGroup(group.id)}
className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
/>
<span className="text-sm text-white">{group.name}</span>
{group.is_system && (
<span className="text-xs text-yellow-700 dark:text-yellow-400">({t('users.system')})</span>
)}
</label>
))}
{groups.length === 0 && (
<p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
)}
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button
variant="secondary"
onClick={closeEditModal}
>
{t('users.modal.cancel')}
</Button>
<Button
onClick={() => handleUpdate(editingUserId)}
disabled={updateMutation.isPending || !formData.username || !!(formData.password && (formData.password !== formData.confirmPassword || formData.password.length < 6))}
>
{updateMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('users.modal.saving')}
</>
) : (
<>
<Save className="w-4 h-4" />
{t('users.modal.saveChanges')}
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteUserId !== null && (
<ConfirmModal
title={t('users.deleteModal.title')}
message={t('users.deleteModal.message')}
confirmText={t('users.deleteModal.confirm')}
variant="danger"
onConfirm={() => {
deleteMutation.mutate(deleteUserId);
setDeleteUserId(null);
}}
onCancel={() => setDeleteUserId(null)}
/>
)}
</div>
);
}
+9 -5
View File
@@ -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