From a9f340f2c99fca358470837c98d24ad01ee13cbf Mon Sep 17 00:00:00 2001
From: MartinNYHC
Date: Wed, 21 Jan 2026 15:58:24 +0100
Subject: [PATCH 1/4] Revert "Added optional authentication and user
management"
---
backend/app/api/routes/auth.py | 213 -
backend/app/api/routes/printers.py | 4 -
backend/app/api/routes/users.py | 205 -
backend/app/core/auth.py | 369 +-
backend/app/core/database.py | 20 -
backend/app/main.py | 4 -
backend/app/models/__init__.py | 2 -
backend/app/models/user.py | 20 -
backend/app/schemas/auth.py | 47 -
backend/tests/conftest.py | 1 -
frontend/package-lock.json | 53 +-
frontend/package.json | 1 -
frontend/src/App.tsx | 110 +-
.../src/__tests__/components/Layout.test.tsx | 3 -
.../src/__tests__/pages/SettingsPage.test.tsx | 3 -
frontend/src/__tests__/utils.tsx | 5 +-
frontend/src/api/client.ts | 125 +-
frontend/src/components/Layout.tsx | 34 +-
frontend/src/contexts/AuthContext.tsx | 116 -
frontend/src/pages/LoginPage.tsx | 103 -
frontend/src/pages/SettingsPage.tsx | 245 +-
frontend/src/pages/SetupPage.tsx | 161 -
frontend/src/pages/UsersPage.tsx | 398 -
frontend/tailwind.config.js | 1 -
requirements.txt | 4 -
static/assets/index-BK0uiRXP.js | 8665 -----------------
static/assets/index-BmODu1qm.css | 1 +
static/assets/index-COZJGA_d.css | 1 -
static/assets/index-DFo1_Rau.js | 8665 +++++++++++++++++
static/index.html | 4 +-
30 files changed, 8775 insertions(+), 10808 deletions(-)
delete mode 100644 backend/app/api/routes/auth.py
delete mode 100644 backend/app/api/routes/users.py
delete mode 100644 backend/app/models/user.py
delete mode 100644 backend/app/schemas/auth.py
delete mode 100644 frontend/src/contexts/AuthContext.tsx
delete mode 100644 frontend/src/pages/LoginPage.tsx
delete mode 100644 frontend/src/pages/SetupPage.tsx
delete mode 100644 frontend/src/pages/UsersPage.tsx
delete mode 100644 static/assets/index-BK0uiRXP.js
create mode 100644 static/assets/index-BmODu1qm.css
delete mode 100644 static/assets/index-COZJGA_d.css
create mode 100644 static/assets/index-DFo1_Rau.js
diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py
deleted file mode 100644
index b50e332f3..000000000
--- a/backend/app/api/routes/auth.py
+++ /dev/null
@@ -1,213 +0,0 @@
-from datetime import timedelta
-
-from fastapi import APIRouter, Depends, HTTPException, status
-from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from backend.app.core.auth import (
- ACCESS_TOKEN_EXPIRE_MINUTES,
- authenticate_user,
- create_access_token,
- get_current_active_user,
- get_password_hash,
- get_user_by_username,
-)
-from backend.app.core.database import get_db
-from backend.app.models.settings import Settings
-from backend.app.models.user import User
-from backend.app.schemas.auth import LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
-
-router = APIRouter(prefix="/auth", tags=["authentication"])
-
-
-async def is_auth_enabled(db: AsyncSession) -> bool:
- """Check if authentication is enabled."""
- result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
- setting = result.scalar_one_or_none()
- return setting and setting.value.lower() == "true"
-
-
-async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
- """Set authentication enabled status."""
- from sqlalchemy import func
- from sqlalchemy.dialects.sqlite import insert as sqlite_insert
-
- stmt = sqlite_insert(Settings).values(key="auth_enabled", value="true" if enabled else "false")
- stmt = stmt.on_conflict_do_update(
- index_elements=["key"], set_={"value": "true" if enabled else "false", "updated_at": func.now()}
- )
- await db.execute(stmt)
- # Note: Don't commit here - let get_db handle it or commit explicitly in the route
-
-
-@router.post("/setup", response_model=SetupResponse)
-async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
- """First-time setup: enable/disable authentication and create admin user."""
- import logging
-
- logger = logging.getLogger(__name__)
-
- try:
- # Check if auth is already configured (prevent re-setup)
- result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
- existing_setting = result.scalar_one_or_none()
-
- # Check if users exist
- user_count_result = await db.execute(select(User))
- user_count = len(user_count_result.scalars().all())
-
- if existing_setting and user_count > 0:
- # Auth already configured and users exist - prevent re-setup
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Authentication is already configured. Use user management to modify users.",
- )
-
- # If auth_enabled is true but no users exist, allow re-setup (recovery scenario)
-
- admin_created = False
-
- if request.auth_enabled:
- if not request.admin_username or not request.admin_password:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Admin username and password are required when enabling authentication",
- )
-
- # Check if admin already exists
- existing_admin = await get_user_by_username(db, request.admin_username)
- if existing_admin:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Admin user already exists",
- )
-
- # Create admin user FIRST (before enabling auth)
- try:
- logger.info(f"Creating admin user: {request.admin_username}")
- admin_user = User(
- username=request.admin_username,
- password_hash=get_password_hash(request.admin_password),
- role="admin",
- is_active=True,
- )
- db.add(admin_user)
- logger.info(f"Admin user added to session: {request.admin_username}")
- admin_created = True
- except Exception as e:
- await db.rollback()
- logger.error(f"Failed to create admin user: {e}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to create admin user: {str(e)}",
- )
-
- # Set auth enabled and commit everything together
- await set_auth_enabled(db, request.auth_enabled)
- await db.commit()
-
- if admin_created:
- await db.refresh(admin_user)
- logger.info(f"Admin user created successfully: {admin_user.id}")
-
- return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
- except HTTPException:
- raise
- except Exception as e:
- logger.error(f"Setup error: {e}", exc_info=True)
- await db.rollback()
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Setup failed: {str(e)}",
- )
-
-
-@router.get("/status")
-async def get_auth_status(db: AsyncSession = Depends(get_db)):
- """Get authentication status (public endpoint)."""
- auth_enabled = await is_auth_enabled(db)
- return {"auth_enabled": auth_enabled, "requires_setup": not auth_enabled}
-
-
-@router.post("/disable", response_model=dict)
-async def disable_auth(
- current_user: User = Depends(get_current_active_user),
- db: AsyncSession = Depends(get_db),
-):
- """Disable authentication (admin only)."""
- import logging
-
- logger = logging.getLogger(__name__)
-
- # Only admins can disable authentication
- if current_user.role != "admin":
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="Only admins can disable authentication",
- )
-
- try:
- await set_auth_enabled(db, False)
- await db.commit()
- logger.info(f"Authentication disabled by admin user: {current_user.username}")
- return {"message": "Authentication disabled successfully", "auth_enabled": False}
- except Exception as e:
- await db.rollback()
- logger.error(f"Failed to disable authentication: {e}", exc_info=True)
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Failed to disable authentication: {str(e)}",
- )
-
-
-@router.post("/login", response_model=LoginResponse)
-async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
- """Login and get access token."""
- # Check if auth is enabled
- auth_enabled = await is_auth_enabled(db)
- if not auth_enabled:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Authentication is not enabled",
- )
-
- user = await authenticate_user(db, request.username, request.password)
- if not user:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Incorrect username or password",
- headers={"WWW-Authenticate": "Bearer"},
- )
-
- access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
- access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
-
- return LoginResponse(
- access_token=access_token,
- token_type="bearer",
- user=UserResponse(
- id=user.id,
- username=user.username,
- role=user.role,
- is_active=user.is_active,
- created_at=user.created_at.isoformat(),
- ),
- )
-
-
-@router.get("/me", response_model=UserResponse)
-async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
- """Get current user information."""
- return UserResponse(
- id=current_user.id,
- username=current_user.username,
- role=current_user.role,
- is_active=current_user.is_active,
- created_at=current_user.created_at.isoformat(),
- )
-
-
-@router.post("/logout")
-async def logout():
- """Logout (client should discard token)."""
- return {"message": "Logged out successfully"}
diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py
index 2f5a86a58..df5c3d7e7 100644
--- a/backend/app/api/routes/printers.py
+++ b/backend/app/api/routes/printers.py
@@ -8,7 +8,6 @@ from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from backend.app.core.auth import RequireAdminIfAuthEnabled
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.models.printer import Printer
@@ -48,7 +47,6 @@ async def list_printers(db: AsyncSession = Depends(get_db)):
async def create_printer(
printer_data: PrinterCreate,
db: AsyncSession = Depends(get_db),
- _current_user=RequireAdminIfAuthEnabled(),
):
"""Add a new printer."""
# Check if serial number already exists
@@ -83,7 +81,6 @@ async def update_printer(
printer_id: int,
printer_data: PrinterUpdate,
db: AsyncSession = Depends(get_db),
- _current_user=RequireAdminIfAuthEnabled(),
):
"""Update a printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -112,7 +109,6 @@ async def delete_printer(
printer_id: int,
delete_archives: bool = True,
db: AsyncSession = Depends(get_db),
- _current_user=RequireAdminIfAuthEnabled(),
):
"""Delete a printer.
diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py
deleted file mode 100644
index 4b6c514ac..000000000
--- a/backend/app/api/routes/users.py
+++ /dev/null
@@ -1,205 +0,0 @@
-from fastapi import APIRouter, Depends, HTTPException, status
-from sqlalchemy import select
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from backend.app.core.auth import RequireAdmin, get_password_hash
-from backend.app.core.database import get_db
-from backend.app.models.user import User
-from backend.app.schemas.auth import UserCreate, UserResponse, UserUpdate
-
-router = APIRouter(prefix="/users", tags=["users"])
-
-
-@router.get("", response_model=list[UserResponse])
-@router.get("/", response_model=list[UserResponse])
-async def list_users(
- current_user: User = RequireAdmin(),
- db: AsyncSession = Depends(get_db),
-):
- """List all users (admin only)."""
- result = await db.execute(select(User).order_by(User.created_at))
- users = result.scalars().all()
- return [
- UserResponse(
- id=user.id,
- username=user.username,
- role=user.role,
- is_active=user.is_active,
- created_at=user.created_at.isoformat(),
- )
- for user in users
- ]
-
-
-@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
-@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
-async def create_user(
- user_data: UserCreate,
- current_user: User = RequireAdmin(),
- db: AsyncSession = Depends(get_db),
-):
- """Create a new user (admin only)."""
- # Check if username already exists
- existing_user = await db.execute(select(User).where(User.username == user_data.username))
- if existing_user.scalar_one_or_none():
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Username already exists",
- )
-
- # Validate role
- if user_data.role not in ["admin", "user"]:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Role must be 'admin' or 'user'",
- )
-
- new_user = User(
- username=user_data.username,
- password_hash=get_password_hash(user_data.password),
- role=user_data.role,
- is_active=True,
- )
- db.add(new_user)
- await db.commit()
- await db.refresh(new_user)
-
- return UserResponse(
- id=new_user.id,
- username=new_user.username,
- role=new_user.role,
- is_active=new_user.is_active,
- created_at=new_user.created_at.isoformat(),
- )
-
-
-@router.get("/{user_id}", response_model=UserResponse)
-async def get_user(
- user_id: int,
- current_user: User = RequireAdmin(),
- db: AsyncSession = Depends(get_db),
-):
- """Get a user by ID (admin only)."""
- result = await db.execute(select(User).where(User.id == user_id))
- user = result.scalar_one_or_none()
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found",
- )
-
- return UserResponse(
- id=user.id,
- username=user.username,
- role=user.role,
- is_active=user.is_active,
- created_at=user.created_at.isoformat(),
- )
-
-
-@router.patch("/{user_id}", response_model=UserResponse)
-async def update_user(
- user_id: int,
- user_data: UserUpdate,
- current_user: User = RequireAdmin(),
- db: AsyncSession = Depends(get_db),
-):
- """Update a user (admin only)."""
- result = await db.execute(select(User).where(User.id == user_id))
- user = result.scalar_one_or_none()
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found",
- )
-
- # Prevent deactivating the last admin
- if user_data.is_active is False and user.role == "admin":
- admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
- admin_count = len(admin_count_result.scalars().all())
- if admin_count <= 1:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Cannot deactivate the last admin user",
- )
-
- # Prevent changing role of last admin
- if user_data.role and user_data.role != "admin" and user.role == "admin":
- admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
- admin_count = len(admin_count_result.scalars().all())
- if admin_count <= 1:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Cannot change role of the last admin user",
- )
-
- if user_data.username is not None:
- # Check if new username already exists
- existing_user = await db.execute(select(User).where(User.username == user_data.username, User.id != user_id))
- if existing_user.scalar_one_or_none():
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Username already exists",
- )
- user.username = user_data.username
-
- if user_data.password is not None:
- user.password_hash = get_password_hash(user_data.password)
-
- if user_data.role is not None:
- if user_data.role not in ["admin", "user"]:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Role must be 'admin' or 'user'",
- )
- user.role = user_data.role
-
- if user_data.is_active is not None:
- user.is_active = user_data.is_active
-
- await db.commit()
- await db.refresh(user)
-
- return UserResponse(
- id=user.id,
- username=user.username,
- role=user.role,
- is_active=user.is_active,
- created_at=user.created_at.isoformat(),
- )
-
-
-@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
-async def delete_user(
- user_id: int,
- current_user: User = RequireAdmin(),
- db: AsyncSession = Depends(get_db),
-):
- """Delete a user (admin only)."""
- result = await db.execute(select(User).where(User.id == user_id))
- user = result.scalar_one_or_none()
- if not user:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="User not found",
- )
-
- # Prevent deleting the last admin
- if user.role == "admin":
- admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
- admin_count = len(admin_count_result.scalars().all())
- if admin_count == 0:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Cannot delete the last admin user",
- )
-
- # Prevent deleting yourself
- if user.id == current_user.id:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail="Cannot delete your own account",
- )
-
- await db.delete(user)
- await db.commit()
diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py
index c30f5881b..5e759ed1b 100644
--- a/backend/app/core/auth.py
+++ b/backend/app/core/auth.py
@@ -1,350 +1,105 @@
+import hashlib
import secrets
-from datetime import datetime, timedelta
-from typing import TYPE_CHECKING, Annotated
+from datetime import datetime
-from fastapi import Depends, Header, HTTPException, status
-from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
-from jose import JWTError, jwt
-from passlib.context import CryptContext
+from fastapi import Depends, Header, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
-from backend.app.core.database import async_session, get_db
-from backend.app.models.settings import Settings
-from backend.app.models.user import User
-
-if TYPE_CHECKING:
- from backend.app.models.api_key import APIKey
-
-# Password hashing
-# Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
-# pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations
-pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
-
-# JWT settings
-SECRET_KEY = "bambuddy-secret-key-change-in-production" # TODO: Move to settings/env
-ALGORITHM = "HS256"
-ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
-
-# HTTP Bearer token
-security = HTTPBearer(auto_error=False)
-
-
-def verify_password(plain_password: str, hashed_password: str) -> bool:
- """Verify a password against a hash.
-
- Uses pbkdf2_sha256 which handles long passwords automatically.
- """
- return pwd_context.verify(plain_password, hashed_password)
-
-
-def get_password_hash(password: str) -> str:
- """Hash a password.
-
- Uses pbkdf2_sha256 which is secure and has no password length limit.
- """
- return pwd_context.hash(password)
-
-
-def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
- """Create a JWT access token."""
- to_encode = data.copy()
- if expires_delta:
- expire = datetime.utcnow() + expires_delta
- else:
- expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
- to_encode.update({"exp": expire})
- encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
- return encoded_jwt
-
-
-async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
- """Get a user by username."""
- result = await db.execute(select(User).where(User.username == username))
- return result.scalar_one_or_none()
-
-
-async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
- """Authenticate a user by username and password."""
- user = await get_user_by_username(db, username)
- if not user:
- return None
- if not verify_password(password, user.password_hash):
- return None
- if not user.is_active:
- return None
- return user
-
-
-async def is_auth_enabled(db: AsyncSession) -> bool:
- """Check if authentication is enabled."""
- try:
- result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
- setting = result.scalar_one_or_none()
- return setting and setting.value.lower() == "true"
- except Exception:
- # If settings table doesn't exist or query fails, assume auth is disabled
- return False
-
-
-async def get_current_user_optional(
- credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
-) -> User | None:
- """Get the current authenticated user from JWT token, or None if not authenticated."""
- if credentials is None:
- return None
-
- try:
- token = credentials.credentials
- payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
- username: str = payload.get("sub")
- if username is None:
- return None
- except JWTError:
- return None
-
- async with async_session() as db:
- user = await get_user_by_username(db, username)
- if user is None or not user.is_active:
- return None
- return user
-
-
-async def get_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> User:
- """Get the current authenticated user from JWT token."""
- credentials_exception = HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Could not validate credentials",
- headers={"WWW-Authenticate": "Bearer"},
- )
- try:
- token = credentials.credentials
- payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
- username: str = payload.get("sub")
- if username is None:
- raise credentials_exception
- except JWTError:
- raise credentials_exception
-
- async with async_session() as db:
- user = await get_user_by_username(db, username)
- if user is None:
- raise credentials_exception
- if not user.is_active:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="User account is disabled",
- )
- return user
-
-
-async def get_current_active_user(current_user: Annotated[User, Depends(get_current_user)]) -> User:
- """Get the current active user (alias for clarity)."""
- return current_user
-
-
-async def require_auth_if_enabled(
- credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
-) -> User | None:
- """Require authentication if auth is enabled, otherwise return None."""
- async with async_session() as db:
- auth_enabled = await is_auth_enabled(db)
- if not auth_enabled:
- return None
-
- if credentials is None:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Authentication required",
- headers={"WWW-Authenticate": "Bearer"},
- )
-
- try:
- token = credentials.credentials
- payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
- username: str = payload.get("sub")
- if username is None:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Could not validate credentials",
- headers={"WWW-Authenticate": "Bearer"},
- )
- except JWTError:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Could not validate credentials",
- headers={"WWW-Authenticate": "Bearer"},
- )
-
- user = await get_user_by_username(db, username)
- if user is None or not user.is_active:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Could not validate credentials",
- headers={"WWW-Authenticate": "Bearer"},
- )
- return user
-
-
-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."""
-
- async def admin_checker(
- current_user: Annotated[User | None, Depends(require_auth_if_enabled)] = None,
- ) -> User | None:
- if current_user is None:
- return None # Auth not enabled, allow access
- if current_user.role != "admin":
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="Requires admin role",
- )
- return current_user
-
- return admin_checker
+from backend.app.core.database import get_db
+from backend.app.models.api_key import APIKey
def generate_api_key() -> tuple[str, str, str]:
"""Generate a new API key.
Returns:
- tuple: (full_key, key_hash, key_prefix)
- - full_key: The complete API key (only shown once on creation)
- - key_hash: Hashed version for storage and verification
- - key_prefix: First 8 characters for display purposes
+ Tuple of (full_key, key_hash, key_prefix)
"""
- # Generate a secure random API key (32 bytes = 64 hex characters)
- full_key = f"bb_{secrets.token_urlsafe(32)}"
- key_hash = get_password_hash(full_key)
- key_prefix = full_key[:8] + "..." if len(full_key) > 8 else full_key
+ # Generate a random 32-byte key and encode as hex (64 chars)
+ full_key = f"bb_{secrets.token_hex(32)}"
+ key_hash = hashlib.sha256(full_key.encode()).hexdigest()
+ key_prefix = full_key[:11] # "bb_" + first 8 chars of token
return full_key, key_hash, key_prefix
+def hash_api_key(key: str) -> str:
+ """Hash an API key for comparison."""
+ return hashlib.sha256(key.encode()).hexdigest()
+
+
async def get_api_key(
- authorization: Annotated[str | None, Header(alias="Authorization")] = None,
- x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
+ x_api_key: str = Header(..., alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
-) -> "APIKey":
- """Get and validate API key from request headers.
+) -> APIKey:
+ """Verify API key and return the key record.
- Checks both 'Authorization: Bearer ' and 'X-API-Key: ' headers.
+ Raises HTTPException if key is invalid, disabled, or expired.
"""
- from fastapi import HTTPException, status
+ key_hash = hash_api_key(x_api_key)
- from backend.app.models.api_key import APIKey
+ result = await db.execute(select(APIKey).where(APIKey.key_hash == key_hash))
+ api_key = result.scalar_one_or_none()
- api_key_value = None
- if x_api_key:
- api_key_value = x_api_key
- elif authorization and authorization.startswith("Bearer "):
- api_key_value = authorization.replace("Bearer ", "")
+ if not api_key:
+ raise HTTPException(status_code=401, detail="Invalid API key")
- if not api_key_value:
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer '",
- )
+ if not api_key.enabled:
+ raise HTTPException(status_code=403, detail="API key is disabled")
- # Get all API keys and check them
- result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
- api_keys = result.scalars().all()
+ if api_key.expires_at and api_key.expires_at < datetime.utcnow():
+ raise HTTPException(status_code=403, detail="API key has expired")
- for api_key in api_keys:
- # Check if key matches (verify against hash)
- if verify_password(api_key_value, api_key.key_hash):
- # Check expiration
- if api_key.expires_at and api_key.expires_at < datetime.now():
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="API key has expired",
- )
- # Update last_used timestamp
- api_key.last_used = datetime.now()
- await db.commit()
- return api_key
+ # Update last_used timestamp
+ api_key.last_used = datetime.utcnow()
- raise HTTPException(
- status_code=status.HTTP_401_UNAUTHORIZED,
- detail="Invalid API key",
- )
+ return api_key
-def check_permission(api_key: "APIKey", permission: str) -> None:
- """Check if API key has the required permission.
+async def get_optional_api_key(
+ x_api_key: str | None = Header(None, alias="X-API-Key"),
+ db: AsyncSession = Depends(get_db),
+) -> APIKey | None:
+ """Get API key if provided, return None otherwise."""
+ if not x_api_key:
+ return None
+
+ try:
+ return await get_api_key(x_api_key, db)
+ except HTTPException:
+ return None
+
+
+def check_permission(api_key: APIKey, permission: str) -> None:
+ """Check if API key has a specific permission.
Args:
- api_key: The API key object
+ api_key: The API key record
permission: One of 'queue', 'control_printer', 'read_status'
- Raises:
- HTTPException: If permission is not granted
+ Raises HTTPException if permission is denied.
"""
- from fastapi import HTTPException, status
-
permission_map = {
- "queue": "can_queue",
- "control_printer": "can_control_printer",
- "read_status": "can_read_status",
+ "queue": api_key.can_queue,
+ "control_printer": api_key.can_control_printer,
+ "read_status": api_key.can_read_status,
}
if permission not in permission_map:
- raise HTTPException(
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
- detail=f"Unknown permission: {permission}",
- )
+ raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}")
- attr_name = permission_map[permission]
- if not getattr(api_key, attr_name, False):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=f"API key does not have '{permission}' permission",
- )
+ if not permission_map[permission]:
+ raise HTTPException(status_code=403, detail=f"API key does not have '{permission}' permission")
-def check_printer_access(api_key: "APIKey", printer_id: int) -> None:
- """Check if API key has access to the specified printer.
+def check_printer_access(api_key: APIKey, printer_id: int) -> None:
+ """Check if API key has access to a specific printer.
Args:
- api_key: The API key object
- printer_id: The printer ID to check access for
+ api_key: The API key record
+ printer_id: The printer ID to check
- Raises:
- HTTPException: If access is denied
+ Raises HTTPException if access is denied.
"""
- from fastapi import HTTPException, status
-
- # If printer_ids is None or empty, access to all printers
- if api_key.printer_ids is None or len(api_key.printer_ids) == 0:
- return
-
- # Check if printer_id is in allowed list
- if printer_id not in api_key.printer_ids:
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail=f"API key does not have access to printer {printer_id}",
- )
-
-
-# Convenience dependencies - these are functions that return Depends objects
-def RequireAdmin():
- """Dependency that requires admin role."""
- return Depends(require_role("admin"))
-
-
-def RequireAdminIfAuthEnabled():
- """Dependency that requires admin role if auth is enabled."""
- return Depends(require_admin_if_auth_enabled())
+ if api_key.printer_ids is not None and printer_id not in api_key.printer_ids:
+ raise HTTPException(status_code=403, detail=f"API key does not have access to printer {printer_id}")
diff --git a/backend/app/core/database.py b/backend/app/core/database.py
index e93070004..0da968cac 100644
--- a/backend/app/core/database.py
+++ b/backend/app/core/database.py
@@ -49,7 +49,6 @@ async def init_db():
project_bom,
settings,
smart_plug,
- user,
)
async with engine.begin() as conn:
@@ -642,25 +641,6 @@ async def run_migrations(conn):
except Exception:
pass
- # Migration: Create users table for authentication
- try:
- await conn.execute(
- text("""
- CREATE TABLE IF NOT EXISTS users (
- id INTEGER PRIMARY KEY,
- username VARCHAR(100) NOT NULL UNIQUE,
- password_hash VARCHAR(255) NOT NULL,
- role VARCHAR(20) NOT NULL DEFAULT 'user',
- is_active BOOLEAN NOT NULL DEFAULT 1,
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
- )
- """)
- )
- await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_username ON users(username)"))
- except Exception:
- pass
-
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
diff --git a/backend/app/main.py b/backend/app/main.py
index dfd7fba78..98b5cc9d3 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -54,7 +54,6 @@ from backend.app.api.routes import (
ams_history,
api_keys,
archives,
- auth,
camera,
cloud,
discovery,
@@ -76,7 +75,6 @@ from backend.app.api.routes import (
support,
system,
updates,
- users,
webhook,
websocket,
)
@@ -1963,8 +1961,6 @@ app = FastAPI(
)
# API routes
-app.include_router(auth.router, prefix=app_settings.api_prefix)
-app.include_router(users.router, prefix=app_settings.api_prefix)
app.include_router(printers.router, prefix=app_settings.api_prefix)
app.include_router(archives.router, prefix=app_settings.api_prefix)
app.include_router(filaments.router, prefix=app_settings.api_prefix)
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 5c1290e82..25dc630db 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -12,7 +12,6 @@ from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
-from backend.app.models.user import User
__all__ = [
"Printer",
@@ -32,5 +31,4 @@ __all__ = [
"PendingUpload",
"LibraryFolder",
"LibraryFile",
- "User",
]
diff --git a/backend/app/models/user.py b/backend/app/models/user.py
deleted file mode 100644
index c18958e7a..000000000
--- a/backend/app/models/user.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from datetime import datetime
-
-from sqlalchemy import DateTime, String, func
-from sqlalchemy.orm import Mapped, mapped_column
-
-from backend.app.core.database import Base
-
-
-class User(Base):
- """User model for authentication and authorization."""
-
- __tablename__ = "users"
-
- id: Mapped[int] = mapped_column(primary_key=True)
- username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
- password_hash: Mapped[str] = mapped_column(String(255))
- role: Mapped[str] = mapped_column(String(20), default="user") # "admin" or "user"
- is_active: Mapped[bool] = mapped_column(default=True)
- created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
- updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
deleted file mode 100644
index 2b2bc9063..000000000
--- a/backend/app/schemas/auth.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from pydantic import BaseModel
-
-
-class LoginRequest(BaseModel):
- username: str
- password: str
-
-
-class LoginResponse(BaseModel):
- access_token: str
- token_type: str = "bearer"
- user: "UserResponse"
-
-
-class UserCreate(BaseModel):
- username: str
- password: str
- role: str = "user"
-
-
-class UserUpdate(BaseModel):
- username: str | None = None
- password: str | None = None
- role: str | None = None
- is_active: bool | None = None
-
-
-class UserResponse(BaseModel):
- id: int
- username: str
- role: str
- is_active: bool
- created_at: str
-
- class Config:
- from_attributes = True
-
-
-class SetupRequest(BaseModel):
- auth_enabled: bool
- admin_username: str | None = None
- admin_password: str | None = None
-
-
-class SetupResponse(BaseModel):
- auth_enabled: bool
- admin_created: bool | None = None
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 8b066b982..27508ffab 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -59,7 +59,6 @@ async def test_engine():
project,
settings,
smart_plug,
- user,
)
async with engine.begin() as conn:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 86246d9f8..763934a58 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,7 +11,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
- "@floating-ui/dom": "^1.7.4",
"@tanstack/react-query": "^5.90.11",
"@tiptap/extension-color": "^3.11.1",
"@tiptap/extension-image": "^3.11.1",
@@ -143,7 +142,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -494,7 +492,6 @@
"url": "https://opencollective.com/csstools"
}
],
- "peer": true,
"engines": {
"node": ">=18"
},
@@ -517,7 +514,6 @@
"url": "https://opencollective.com/csstools"
}
],
- "peer": true,
"engines": {
"node": ">=18"
}
@@ -545,7 +541,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -1186,6 +1181,7 @@
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
+ "optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
@@ -1194,8 +1190,7 @@
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
- "license": "MIT",
- "peer": true,
+ "optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
@@ -1205,7 +1200,8 @@
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
- "license": "MIT"
+ "license": "MIT",
+ "optional": true
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
@@ -2312,7 +2308,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.1.tgz",
"integrity": "sha512-q7uzYrCq40JOIi6lceWe2HuA8tSr97iPwP/xtJd0bZjyL1rWhUyqxMb7y+aq4RcELrx/aNRa2JIvLtRRdy02Dg==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2561,7 +2556,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.1.tgz",
"integrity": "sha512-XJRN9pOPMi3SsaKv4qM8WBEi3YDrjXYtYlAlZutQe1JpdKykSjLwwYq7k3V8UHqR3YKxyOV8HTYOYoOaZ9TMTQ==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2667,7 +2661,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.11.1.tgz",
"integrity": "sha512-KLLrABvf609/Z4dPChRowvpqeefYiq5csEj4Ogfp4EFd3KqDvPZIoFepau1+BW4gOAlm8UK+ig+fOLgnUzH7ww==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2694,7 +2687,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.1.tgz",
"integrity": "sha512-/xXJdV+EVvSQv2slvAUChb5iGVv5K0EqBqxPGAAuBHdIc4Y7Id1aaKKSiyDmqon+kjSnnQIIda9oUt+o/Z66uA==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2709,7 +2701,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.1.tgz",
"integrity": "sha512-8RIUhlEoCFGsbdNb+EUdQctG1Wnd7rl4wlMLS6giO7UcZT5dVfg625eMZVrl0/kA7JBJdKLIuqNmzzQ0MxsJEw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -2808,7 +2799,8 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -2960,7 +2952,6 @@
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2970,7 +2961,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2980,7 +2970,6 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3070,7 +3059,6 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
@@ -3440,7 +3428,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3625,7 +3612,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -4188,7 +4174,8 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -4380,7 +4367,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5068,7 +5054,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -5841,6 +5826,7 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
+ "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -6329,7 +6315,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -6357,7 +6342,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6389,6 +6373,7 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -6403,6 +6388,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -6414,7 +6400,8 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
@@ -6534,7 +6521,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -6564,7 +6550,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -6613,7 +6598,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
"integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -6644,7 +6628,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6654,7 +6637,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -6701,7 +6683,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -6830,8 +6811,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -7428,7 +7408,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7567,7 +7546,6 @@
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -8129,7 +8107,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
- "peer": true,
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
@@ -8627,7 +8604,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
- "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -9026,7 +9002,6 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/frontend/package.json b/frontend/package.json
index 3adc4e197..93f5d2fbb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -17,7 +17,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
- "@floating-ui/dom": "^1.7.4",
"@tanstack/react-query": "^5.90.11",
"@tiptap/extension-color": "^3.11.1",
"@tiptap/extension-image": "^3.11.1",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 48cc0f158..5ec765c64 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,4 +1,4 @@
-import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Layout } from './components/Layout';
import { PrintersPage } from './pages/PrintersPage';
@@ -14,13 +14,9 @@ import { FileManagerPage } from './pages/FileManagerPage';
import { CameraPage } from './pages/CameraPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import { SystemInfoPage } from './pages/SystemInfoPage';
-import { LoginPage } from './pages/LoginPage';
-import { SetupPage } from './pages/SetupPage';
-import { UsersPage } from './pages/UsersPage';
import { useWebSocket } from './hooks/useWebSocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
-import { AuthProvider, useAuth } from './contexts/AuthContext';
const queryClient = new QueryClient({
defaultOptions: {
@@ -36,95 +32,33 @@ function WebSocketProvider({ children }: { children: React.ReactNode }) {
return <>{children}>;
}
-function ProtectedRoute({ children }: { children: React.ReactNode }) {
- const { authEnabled, loading, user } = useAuth();
-
- if (loading) {
- return Loading...
;
- }
-
- if (authEnabled && !user) {
- return ;
- }
-
- return <>{children}>;
-}
-
-function AdminRoute({ children }: { children: React.ReactNode }) {
- const { authEnabled, loading, user } = useAuth();
-
- if (loading) {
- return Loading...
;
- }
-
- // If auth is not enabled, allow access (backward compatibility)
- if (!authEnabled) {
- return <>{children}>;
- }
-
- // If auth is enabled but no user, redirect to login
- if (!user) {
- return ;
- }
-
- // If user is not admin, redirect to home
- if (user.role !== 'admin') {
- return ;
- }
-
- return <>{children}>;
-}
-
-function SetupRoute({ children }: { children: React.ReactNode }) {
- const { authEnabled, loading } = useAuth();
-
- if (loading) {
- return Loading...
;
- }
-
- if (authEnabled) {
- return ;
- }
-
- return <>{children}>;
-}
-
function App() {
return (
-
-
-
- {/* Setup page - only accessible if auth not enabled */}
- } />
+
+
+ {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
+ } />
- {/* Login page */}
- } />
-
- {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
- } />
-
- {/* Main app with WebSocket for real-time updates */}
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
-
+ {/* Main app with WebSocket for real-time updates */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
diff --git a/frontend/src/__tests__/components/Layout.test.tsx b/frontend/src/__tests__/components/Layout.test.tsx
index e63b73e3f..4395b096d 100644
--- a/frontend/src/__tests__/components/Layout.test.tsx
+++ b/frontend/src/__tests__/components/Layout.test.tsx
@@ -49,9 +49,6 @@ describe('Layout', () => {
}),
http.get('/api/v1/updates/check', () => {
return HttpResponse.json({ update_available: false });
- }),
- http.get('/api/v1/auth/status', () => {
- return HttpResponse.json({ auth_enabled: false, requires_setup: false });
})
);
});
diff --git a/frontend/src/__tests__/pages/SettingsPage.test.tsx b/frontend/src/__tests__/pages/SettingsPage.test.tsx
index 9f318f7b6..ed7b3b084 100644
--- a/frontend/src/__tests__/pages/SettingsPage.test.tsx
+++ b/frontend/src/__tests__/pages/SettingsPage.test.tsx
@@ -60,9 +60,6 @@ describe('SettingsPage', () => {
}),
http.get('/api/v1/virtual-printer/status', () => {
return HttpResponse.json({ running: false });
- }),
- http.get('/api/v1/auth/status', () => {
- return HttpResponse.json({ auth_enabled: false, requires_setup: false });
})
);
});
diff --git a/frontend/src/__tests__/utils.tsx b/frontend/src/__tests__/utils.tsx
index 29bde1250..42702c837 100644
--- a/frontend/src/__tests__/utils.tsx
+++ b/frontend/src/__tests__/utils.tsx
@@ -9,7 +9,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from '../contexts/ThemeContext';
import { ToastProvider } from '../contexts/ToastContext';
-import { AuthProvider } from '../contexts/AuthContext';
// Create a new QueryClient for each test
function createTestQueryClient() {
@@ -37,9 +36,7 @@ function AllProviders({ children }: AllProvidersProps) {
-
- {children}
-
+ {children}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index ff07bc04f..024eb99be 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1,47 +1,19 @@
const API_BASE = '/api/v1';
-// Auth token storage
-let authToken: string | null = localStorage.getItem('auth_token');
-
-export function setAuthToken(token: string | null) {
- authToken = token;
- if (token) {
- localStorage.setItem('auth_token', token);
- } else {
- localStorage.removeItem('auth_token');
- }
-}
-
-export function getAuthToken(): string | null {
- return authToken;
-}
-
async function request(
endpoint: string,
options: RequestInit = {}
): Promise {
- const headers: Record = {
- 'Content-Type': 'application/json',
- ...options.headers as Record,
- };
-
- // Add auth token if available
- if (authToken) {
- headers['Authorization'] = `Bearer ${authToken}`;
- }
-
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
cache: 'no-store', // Prevent browser caching of API responses
- headers,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ },
});
if (!response.ok) {
- // Handle 401 Unauthorized - clear token and redirect to login
- if (response.status === 401) {
- setAuthToken(null);
- // Don't throw here - let the auth context handle redirect
- }
const error = await response.json().catch(() => ({}));
const detail = error.detail;
const message = typeof detail === 'string'
@@ -1403,97 +1375,8 @@ export interface ExternalLinkUpdate {
icon?: string;
}
-// Auth types
-export interface LoginRequest {
- username: string;
- password: string;
-}
-
-export interface LoginResponse {
- access_token: string;
- token_type: string;
- user: UserResponse;
-}
-
-export interface UserResponse {
- id: number;
- username: string;
- role: string;
- is_active: boolean;
- created_at: string;
-}
-
-export interface UserCreate {
- username: string;
- password: string;
- role: string;
-}
-
-export interface UserUpdate {
- username?: string;
- password?: string;
- role?: string;
- is_active?: boolean;
-}
-
-export interface SetupRequest {
- auth_enabled: boolean;
- admin_username?: string;
- admin_password?: string;
-}
-
-export interface SetupResponse {
- auth_enabled: boolean;
- admin_created?: boolean;
-}
-
-export interface AuthStatus {
- auth_enabled: boolean;
- requires_setup: boolean;
-}
-
// API functions
export const api = {
- // Authentication
- getAuthStatus: () => request('/auth/status'),
- setupAuth: (data: SetupRequest) =>
- request('/auth/setup', {
- method: 'POST',
- body: JSON.stringify(data),
- }),
- login: (data: LoginRequest) =>
- request('/auth/login', {
- method: 'POST',
- body: JSON.stringify(data),
- }),
- logout: () =>
- request<{ message: string }>('/auth/logout', {
- method: 'POST',
- }),
- getCurrentUser: () => request('/auth/me'),
- disableAuth: () =>
- request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
- method: 'POST',
- }),
-
- // Users (admin only)
- getUsers: () => request('/users/'),
- getUser: (id: number) => request(`/users/${id}`),
- createUser: (data: UserCreate) =>
- request('/users/', {
- method: 'POST',
- body: JSON.stringify(data),
- }),
- updateUser: (id: number, data: UserUpdate) =>
- request(`/users/${id}`, {
- method: 'PATCH',
- body: JSON.stringify(data),
- }),
- deleteUser: (id: number) =>
- request(`/users/${id}`, {
- method: 'DELETE',
- }),
-
// Printers
getPrinters: () => request('/printers/'),
getPrinter: (id: number) => request(`/printers/${id}`),
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index cf9f3a0c6..d05f90d6c 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -9,7 +9,6 @@ import { useQuery } from '@tanstack/react-query';
import { api, supportApi, pendingUploadsApi } from '../api/client';
import { getIconByName } from './IconPicker';
import { useIsMobile } from '../hooks/useIsMobile';
-import { useAuth } from '../contexts/AuthContext';
interface NavItem {
id: string;
@@ -69,7 +68,6 @@ export function Layout() {
const { mode, toggleMode } = useTheme();
const { t } = useTranslation();
const isMobile = useIsMobile();
- const { user, authEnabled, logout } = useAuth();
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
const stored = localStorage.getItem('sidebarExpanded');
return stored !== 'false';
@@ -170,20 +168,12 @@ export function Layout() {
const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
// Compute the ordered sidebar: include stored order + any new items
- // Filter out 'settings' for users with 'user' role
const orderedSidebarIds = (() => {
const result: string[] = [];
const seen = new Set();
-
- // Determine if settings should be hidden (user role and auth enabled)
- const hideSettings = authEnabled && user?.role === 'user';
// Add items in stored order
for (const id of sidebarOrder) {
- // Skip settings if user is not admin
- if (hideSettings && id === 'settings') {
- continue;
- }
if (navItemsMap.has(id) || extLinksMap.has(id)) {
result.push(id);
seen.add(id);
@@ -192,10 +182,6 @@ export function Layout() {
// Add any new internal nav items not in stored order
for (const item of defaultNavItems) {
- // Skip settings if user is not admin
- if (hideSettings && item.id === 'settings') {
- continue;
- }
if (!seen.has(item.id)) {
result.push(item.id);
seen.add(item.id);
@@ -579,15 +565,6 @@ export function Layout() {
>
{mode === 'dark' ? : }
- {authEnabled && user && (
-
-
-
- )}
{/* Bottom row: version */}
@@ -665,15 +642,6 @@ export function Layout() {
>
{mode === 'dark' ? : }
- {authEnabled && user && (
-
-
-
- )}
)}
diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx
deleted file mode 100644
index 9429dad9b..000000000
--- a/frontend/src/contexts/AuthContext.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import React, { createContext, useContext, useEffect, useState } from 'react';
-import { api, getAuthToken, setAuthToken } from '../api/client';
-import type { UserResponse } from '../api/client';
-
-interface AuthContextType {
- user: UserResponse | null;
- authEnabled: boolean;
- loading: boolean;
- login: (username: string, password: string) => Promise;
- logout: () => void;
- refreshUser: () => Promise;
- refreshAuth: () => Promise;
-}
-
-const AuthContext = createContext(undefined);
-
-export function AuthProvider({ children }: { children: React.ReactNode }) {
- const [user, setUser] = useState(null);
- const [authEnabled, setAuthEnabled] = useState(false);
- const [loading, setLoading] = useState(true);
-
- const checkAuthStatus = async () => {
- try {
- const status = await api.getAuthStatus();
- setAuthEnabled(status.auth_enabled);
-
- if (status.auth_enabled) {
- const token = getAuthToken();
- if (token) {
- try {
- const currentUser = await api.getCurrentUser();
- setUser(currentUser);
- } catch {
- // Token invalid, clear it
- setAuthToken(null);
- setUser(null);
- }
- } else {
- setUser(null);
- }
- } else {
- // Auth not enabled, allow access
- setUser(null);
- // Check if setup is needed
- if (status.requires_setup && window.location.pathname !== '/setup') {
- window.location.href = '/setup';
- }
- }
- } catch (error) {
- console.error('Failed to check auth status:', error);
- setAuthEnabled(false);
- setUser(null);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- checkAuthStatus();
- }, []);
-
- const login = async (username: string, password: string) => {
- const response = await api.login({ username, password });
- setAuthToken(response.access_token);
- setUser(response.user);
- };
-
- const logout = () => {
- setAuthToken(null);
- setUser(null);
- api.logout().catch(() => {
- // Ignore logout errors
- });
- window.location.href = '/login';
- };
-
- const refreshUser = async () => {
- if (authEnabled && getAuthToken()) {
- try {
- const currentUser = await api.getCurrentUser();
- setUser(currentUser);
- } catch {
- setAuthToken(null);
- setUser(null);
- }
- }
- };
-
- const refreshAuth = async () => {
- await checkAuthStatus();
- };
-
- return (
-
- {children}
-
- );
-}
-
-export function useAuth() {
- const context = useContext(AuthContext);
- if (context === undefined) {
- throw new Error('useAuth must be used within an AuthProvider');
- }
- return context;
-}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
deleted file mode 100644
index f52d43c5e..000000000
--- a/frontend/src/pages/LoginPage.tsx
+++ /dev/null
@@ -1,103 +0,0 @@
-import { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useMutation } from '@tanstack/react-query';
-import { useAuth } from '../contexts/AuthContext';
-import { useToast } from '../contexts/ToastContext';
-import { useTheme } from '../contexts/ThemeContext';
-
-export function LoginPage() {
- const navigate = useNavigate();
- const { login } = useAuth();
- const { showToast } = useToast();
- const { mode } = useTheme();
- const [username, setUsername] = useState('');
- const [password, setPassword] = useState('');
-
- const loginMutation = useMutation({
- mutationFn: () => login(username, password),
- onSuccess: () => {
- showToast('Logged in successfully');
- navigate('/');
- },
- onError: (error: Error) => {
- showToast(error.message || 'Login failed', 'error');
- },
- });
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (!username || !password) {
- showToast('Please enter username and password', 'error');
- return;
- }
- loginMutation.mutate();
- };
-
- return (
-
-
-
-
-
-
-
- Bambuddy Login
-
-
- Sign in to your account
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
index 7204698ba..a1a2b0545 100644
--- a/frontend/src/pages/SettingsPage.tsx
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -1,9 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video } from 'lucide-react';
import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
-import { useAuth } from '../contexts/AuthContext';
import { formatDateOnly } from '../utils/date';
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
@@ -31,10 +29,8 @@ import { Palette } from 'lucide-react';
export function SettingsPage() {
const queryClient = useQueryClient();
- const navigate = useNavigate();
const { t, i18n } = useTranslation();
const { showToast, showPersistentToast, dismissToast } = useToast();
- const { authEnabled, user, refreshAuth } = useAuth();
const {
mode,
darkStyle, darkBackground, darkAccent,
@@ -50,7 +46,7 @@ export function SettingsPage() {
const [editingTemplate, setEditingTemplate] = useState(null);
const [showLogViewer, setShowLogViewer] = useState(false);
const [defaultView, setDefaultViewState] = useState(getDefaultView());
- const [activeTab, setActiveTab] = useState<'general' | 'network' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer' | 'users'>('general');
+ const [activeTab, setActiveTab] = useState<'general' | 'network' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer'>('general');
const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
const [newAPIKeyName, setNewAPIKeyName] = useState('');
const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -70,7 +66,6 @@ export function SettingsPage() {
const [showRestoreModal, setShowRestoreModal] = useState(false);
const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
- const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false);
// Home Assistant test connection state
const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
@@ -479,7 +474,7 @@ export function SettingsPage() {
{/* Tab Navigation */}
-
+
setActiveTab('general')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
@@ -573,20 +568,6 @@ export function SettingsPage() {
Virtual Printer
- setActiveTab('users')}
- className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
- activeTab === 'users'
- ? 'text-bambu-green border-bambu-green'
- : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
- }`}
- >
-
- Users
- {authEnabled && (
-
- )}
-
{/* General Tab */}
@@ -2885,226 +2866,6 @@ export function SettingsPage() {
)}
-
- {/* Users Tab */}
- {activeTab === 'users' && (
-
-
-
-
-
- User Authentication
-
-
- Enable authentication to secure your Bambuddy instance and manage user access.
-
-
-
-
-
- {!authEnabled ? (
-
-
-
- {authEnabled ? (
-
- ) : (
-
- )}
-
-
-
Authentication Disabled
-
- Your Bambuddy instance is currently accessible without authentication.
-
-
-
-
-
-
- Enable authentication to:
-
-
-
-
- Require login to access the system
-
-
-
- Manage multiple users with different roles
-
-
-
- Control access to printer settings and user management
-
-
-
-
navigate('/setup')}
- className="w-full"
- >
-
- Activate Authentication
-
-
-
- ) : (
-
-
-
-
-
-
-
Authentication Enabled
-
- Your Bambuddy instance is secured with authentication.
-
-
-
-
- {user && (
-
-
-
-
Current User
-
{user.username}
-
- Role: {user.role}
-
-
-
- {user.role === 'admin' ? 'Admin' : 'User'}
-
-
-
- )}
-
-
- navigate('/users')}
- className="w-full"
- variant="secondary"
- >
-
- Manage Users
-
-
- {user?.role === 'admin' && (
- setShowDisableAuthConfirm(true)}
- className="w-full"
- variant="secondary"
- >
-
- Disable Authentication
-
- )}
-
-
- )}
-
-
-
-
- {authEnabled && (
-
-
-
-
- Role Permissions
-
-
- Overview of what each role can do.
-
-
-
-
-
-
-
-
-
-
-
-
- Manage printer settings
-
-
-
- Create, edit, and delete users
-
-
-
- Access all system features
-
-
-
-
-
-
-
-
-
-
-
-
-
- Send print jobs
-
-
-
- Manage files and archives
-
-
-
- Manage filament
-
-
-
-
-
-
- )}
-
- )}
-
- {/* Disable Authentication Confirmation Modal */}
- {showDisableAuthConfirm && (
- {
- try {
- await api.disableAuth();
- showToast('Authentication disabled successfully', 'success');
- await refreshAuth();
- setShowDisableAuthConfirm(false);
- // Refresh the page to ensure all protected routes are accessible
- window.location.href = '/';
- } catch (error: unknown) {
- const message = error instanceof Error ? error.message : 'Failed to disable authentication';
- showToast(message, 'error');
- }
- }}
- onCancel={() => setShowDisableAuthConfirm(false)}
- />
- )}
);
}
diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx
deleted file mode 100644
index 7095d3abc..000000000
--- a/frontend/src/pages/SetupPage.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useMutation } from '@tanstack/react-query';
-import { api } from '../api/client';
-import { useToast } from '../contexts/ToastContext';
-import { useTheme } from '../contexts/ThemeContext';
-
-export function SetupPage() {
- const navigate = useNavigate();
- const { showToast } = useToast();
- const { mode } = useTheme();
- const [authEnabled, setAuthEnabled] = useState(false);
- const [adminUsername, setAdminUsername] = useState('');
- const [adminPassword, setAdminPassword] = useState('');
- const [confirmPassword, setConfirmPassword] = useState('');
-
- const setupMutation = useMutation({
- mutationFn: () =>
- api.setupAuth({
- auth_enabled: authEnabled,
- admin_username: authEnabled ? adminUsername : undefined,
- admin_password: authEnabled ? adminPassword : undefined,
- }),
- onSuccess: (data) => {
- if (data.auth_enabled && data.admin_created) {
- showToast('Authentication enabled and admin user created');
- navigate('/login');
- } else {
- showToast('Setup completed');
- navigate('/');
- }
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
-
- if (authEnabled) {
- if (!adminUsername || !adminPassword) {
- showToast('Please enter admin username and password', 'error');
- return;
- }
- if (adminPassword !== confirmPassword) {
- showToast('Passwords do not match', 'error');
- return;
- }
- if (adminPassword.length < 6) {
- showToast('Password must be at least 6 characters', 'error');
- return;
- }
- }
-
- setupMutation.mutate();
- };
-
- return (
-
-
-
-
-
-
-
- Bambuddy Setup
-
-
- Configure authentication for your Bambuddy instance
-
-
-
-
-
-
- );
-}
diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx
deleted file mode 100644
index 9f1e8a6a6..000000000
--- a/frontend/src/pages/UsersPage.tsx
+++ /dev/null
@@ -1,398 +0,0 @@
-import { useState, useEffect } from 'react';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield } from 'lucide-react';
-import { api } from '../api/client';
-import type { UserCreate, UserUpdate } 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';
-
-export function UsersPage() {
- const { user: currentUser } = useAuth();
- const { showToast } = useToast();
- const queryClient = useQueryClient();
- const [showCreateModal, setShowCreateModal] = useState(false);
- const [editingUser, setEditingUser] = useState(null);
- const [deleteUserId, setDeleteUserId] = useState(null);
- const [formData, setFormData] = useState({
- username: '',
- password: '',
- role: 'user',
- });
-
- // Close modal on Escape key
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape' && showCreateModal) {
- setShowCreateModal(false);
- setFormData({ username: '', password: '', role: 'user' });
- }
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [showCreateModal]);
-
- const { data: users = [], isLoading } = useQuery({
- queryKey: ['users'],
- queryFn: () => api.getUsers(),
- });
-
- const createMutation = useMutation({
- mutationFn: (data: UserCreate) => api.createUser(data),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['users'] });
- setShowCreateModal(false);
- setFormData({ username: '', password: '', role: 'user' });
- showToast('User created successfully');
- },
- 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'] });
- setEditingUser(null);
- setFormData({ username: '', password: '', role: 'user' });
- showToast('User updated successfully');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
-
- const deleteMutation = useMutation({
- mutationFn: (id: number) => api.deleteUser(id),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ['users'] });
- showToast('User deleted successfully');
- },
- onError: (error: Error) => {
- showToast(error.message, 'error');
- },
- });
-
- const handleCreate = () => {
- if (!formData.username || !formData.password) {
- showToast('Please fill in all required fields', 'error');
- return;
- }
- createMutation.mutate(formData);
- };
-
- const handleUpdate = (id: number) => {
- const updateData: UserUpdate = {
- username: formData.username || undefined,
- password: formData.password || undefined,
- role: formData.role,
- };
- // Remove password if empty
- if (!updateData.password) {
- delete updateData.password;
- }
- updateMutation.mutate({ id, data: updateData });
- };
-
- const handleDelete = (id: number) => {
- setDeleteUserId(id);
- };
-
- const startEdit = (user: { id: number; username: string; role: string }) => {
- setEditingUser(user.id);
- setFormData({
- username: user.username,
- password: '',
- role: user.role,
- });
- };
-
- if (currentUser?.role !== 'admin') {
- return (
-
-
-
-
-
-
You do not have permission to access this page.
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- User Management
-
-
- Manage users and their access to your Bambuddy instance
-
-
-
{
- setShowCreateModal(true);
- setFormData({ username: '', password: '', role: 'user' });
- }}
- >
-
- Create User
-
-
-
- {isLoading ? (
-
-
-
- ) : (
-
-
-
- )}
-
- {/* Create User Modal */}
- {showCreateModal && (
-
{
- setShowCreateModal(false);
- setFormData({ username: '', password: '', role: 'user' });
- }}
- >
-
e.stopPropagation()}
- >
-
-
-
-
-
Create User
-
-
{
- setShowCreateModal(false);
- setFormData({ username: '', password: '', role: 'user' });
- }}
- >
-
-
-
-
-
-
-
-
- Username
-
- setFormData({ ...formData, username: e.target.value })}
- className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
- placeholder="Enter username"
- autoComplete="username"
- />
-
-
-
- Password
-
- 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="Enter password"
- autoComplete="new-password"
- minLength={6}
- />
-
-
-
- Role
-
- setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
- className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
- >
- User
- Admin
-
-
-
-
-
{
- setShowCreateModal(false);
- setFormData({ username: '', password: '', role: 'user' });
- }}
- >
- Cancel
-
-
- {createMutation.isPending ? (
- <>
-
- Creating...
- >
- ) : (
- <>
-
- Create User
- >
- )}
-
-
-
-
-
- )}
-
- {/* Delete Confirmation Modal */}
- {deleteUserId !== null && (
-
{
- deleteMutation.mutate(deleteUserId);
- setDeleteUserId(null);
- }}
- onCancel={() => setDeleteUserId(null)}
- />
- )}
-
- );
-}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index ceb6da833..32f1598ae 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -16,7 +16,6 @@ export default {
dark: '#1a1a1a',
'dark-secondary': '#2d2d2d',
'dark-tertiary': '#3d3d3d',
- card: '#2d2d2d', // Same as dark-secondary for card backgrounds
gray: '#808080',
'gray-light': '#a0a0a0',
'gray-dark': '#4a4a4a',
diff --git a/requirements.txt b/requirements.txt
index 27238e913..5f3c6ad73 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -37,10 +37,6 @@ qrcode[pil]>=7.4.0
# System monitoring
psutil>=6.0.0
-# Authentication
-python-jose[cryptography]>=3.3.0
-passlib[bcrypt]>=1.7.4
-
# Development
pytest>=8.0.0
pytest-asyncio>=0.23.0
diff --git a/static/assets/index-BK0uiRXP.js b/static/assets/index-BK0uiRXP.js
deleted file mode 100644
index 238a186c4..000000000
--- a/static/assets/index-BK0uiRXP.js
+++ /dev/null
@@ -1,8665 +0,0 @@
-function wae(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const a of i)if(a.type==="childList")for(const o of a.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const a={};return i.integrity&&(a.integrity=i.integrity),i.referrerPolicy&&(a.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?a.credentials="include":i.crossOrigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function r(i){if(i.ep)return;i.ep=!0;const a=n(i);fetch(i.href,a)}})();var Gv=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function jo(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var gA={exports:{}},zx={};var fU;function Sae(){if(fU)return zx;fU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,a){var o=null;if(a!==void 0&&(o=""+a),i.key!==void 0&&(o=""+i.key),"key"in i){a={};for(var l in i)l!=="key"&&(a[l]=i[l])}else a=i;return i=a.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:a}}return zx.Fragment=e,zx.jsx=n,zx.jsxs=n,zx}var mU;function Mae(){return mU||(mU=1,gA.exports=Sae()),gA.exports}var s=Mae(),xA={exports:{}},sn={};var pU;function Eae(){if(pU)return sn;pU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),a=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),u=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),f=Symbol.for("react.activity"),m=Symbol.iterator;function p(U){return U===null||typeof U!="object"?null:(U=m&&U[m]||U["@@iterator"],typeof U=="function"?U:null)}var b={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},y=Object.assign,x={};function v(U,G,Q){this.props=U,this.context=G,this.refs=x,this.updater=Q||b}v.prototype.isReactComponent={},v.prototype.setState=function(U,G){if(typeof U!="object"&&typeof U!="function"&&U!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,U,G,"setState")},v.prototype.forceUpdate=function(U){this.updater.enqueueForceUpdate(this,U,"forceUpdate")};function M(){}M.prototype=v.prototype;function S(U,G,Q){this.props=U,this.context=G,this.refs=x,this.updater=Q||b}var A=S.prototype=new M;A.constructor=S,y(A,v.prototype),A.isPureReactComponent=!0;var T=Array.isArray;function k(){}var N={H:null,A:null,T:null,S:null},R=Object.prototype.hasOwnProperty;function C(U,G,Q){var te=Q.ref;return{$$typeof:t,type:U,key:G,ref:te!==void 0?te:null,props:Q}}function P(U,G){return C(U.type,G,U.props)}function F(U){return typeof U=="object"&&U!==null&&U.$$typeof===t}function I(U){var G={"=":"=0",":":"=2"};return"$"+U.replace(/[=:]/g,function(Q){return G[Q]})}var X=/\/+/g;function D(U,G){return typeof U=="object"&&U!==null&&U.key!=null?I(""+U.key):G.toString(36)}function K(U){switch(U.status){case"fulfilled":return U.value;case"rejected":throw U.reason;default:switch(typeof U.status=="string"?U.then(k,k):(U.status="pending",U.then(function(G){U.status==="pending"&&(U.status="fulfilled",U.value=G)},function(G){U.status==="pending"&&(U.status="rejected",U.reason=G)})),U.status){case"fulfilled":return U.value;case"rejected":throw U.reason}}throw U}function Y(U,G,Q,te,se){var ue=typeof U;(ue==="undefined"||ue==="boolean")&&(U=null);var pe=!1;if(U===null)pe=!0;else switch(ue){case"bigint":case"string":case"number":pe=!0;break;case"object":switch(U.$$typeof){case t:case e:pe=!0;break;case d:return pe=U._init,Y(pe(U._payload),G,Q,te,se)}}if(pe)return se=se(U),pe=te===""?"."+D(U,0):te,T(se)?(Q="",pe!=null&&(Q=pe.replace(X,"$&/")+"/"),Y(se,G,Q,"",function(Me){return Me})):se!=null&&(F(se)&&(se=P(se,Q+(se.key==null||U&&U.key===se.key?"":(""+se.key).replace(X,"$&/")+"/")+pe)),G.push(se)),1;pe=0;var Ne=te===""?".":te+":";if(T(U))for(var xe=0;xe>>1,ie=Y[ae];if(0>>1;aei(Q,ee))tei(se,Q)?(Y[ae]=se,Y[te]=ee,ae=te):(Y[ae]=Q,Y[G]=ee,ae=G);else if(tei(se,ee))Y[ae]=se,Y[te]=ee,ae=te;else break e}}return W}function i(Y,W){var ee=Y.sortIndex-W.sortIndex;return ee!==0?ee:Y.id-W.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var a=performance;t.unstable_now=function(){return a.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],u=[],d=1,f=null,m=3,p=!1,b=!1,y=!1,x=!1,v=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,S=typeof setImmediate<"u"?setImmediate:null;function A(Y){for(var W=n(u);W!==null;){if(W.callback===null)r(u);else if(W.startTime<=Y)r(u),W.sortIndex=W.expirationTime,e(c,W);else break;W=n(u)}}function T(Y){if(y=!1,A(Y),!b)if(n(c)!==null)b=!0,k||(k=!0,I());else{var W=n(u);W!==null&&K(T,W.startTime-Y)}}var k=!1,N=-1,R=5,C=-1;function P(){return x?!0:!(t.unstable_now()-CY&&P());){var ae=f.callback;if(typeof ae=="function"){f.callback=null,m=f.priorityLevel;var ie=ae(f.expirationTime<=Y);if(Y=t.unstable_now(),typeof ie=="function"){f.callback=ie,A(Y),W=!0;break t}f===n(c)&&r(c),A(Y)}else r(c);f=n(c)}if(f!==null)W=!0;else{var U=n(u);U!==null&&K(T,U.startTime-Y),W=!1}}break e}finally{f=null,m=ee,p=!1}W=void 0}}finally{W?I():k=!1}}}var I;if(typeof S=="function")I=function(){S(F)};else if(typeof MessageChannel<"u"){var X=new MessageChannel,D=X.port2;X.port1.onmessage=F,I=function(){D.postMessage(null)}}else I=function(){v(F,0)};function K(Y,W){N=v(function(){Y(t.unstable_now())},W)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(Y){Y.callback=null},t.unstable_forceFrameRate=function(Y){0>Y||125ae?(Y.sortIndex=ee,e(u,Y),n(c)===null&&Y===n(u)&&(y?(M(N),N=-1):y=!0,K(T,ee-ae))):(Y.sortIndex=ie,e(c,Y),b||p||(b=!0,k||(k=!0,I()))),Y},t.unstable_shouldYield=P,t.unstable_wrapCallback=function(Y){var W=m;return function(){var ee=m;m=W;try{return Y.apply(this,arguments)}finally{m=ee}}}})(vA)),vA}var bU;function Tae(){return bU||(bU=1,yA.exports=Aae()),yA.exports}var _A={exports:{}},ya={};var yU;function Cae(){if(yU)return ya;yU=1;var t=Ag();function e(c){var u="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),_A.exports=Cae(),_A.exports}var _U;function kae(){if(_U)return Bx;_U=1;var t=Tae(),e=Ag(),n=YG();function r(h){var g="https://react.dev/errors/"+h;if(1ie||(h.current=ae[ie],ae[ie]=null,ie--)}function Q(h,g){ie++,ae[ie]=h.current,h.current=g}var te=U(null),se=U(null),ue=U(null),pe=U(null);function Ne(h,g){switch(Q(ue,g),Q(se,h),Q(te,null),g.nodeType){case 9:case 11:h=(h=g.documentElement)&&(h=h.namespaceURI)?LF(h):0;break;default:if(h=g.tagName,g=g.namespaceURI)g=LF(g),h=IF(g,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}G(te),Q(te,h)}function xe(){G(te),G(se),G(ue)}function Me(h){h.memoizedState!==null&&Q(pe,h);var g=te.current,_=IF(g,h.type);g!==_&&(Q(se,h),Q(te,_))}function Pe(h){se.current===h&&(G(te),G(se)),pe.current===h&&(G(pe),Lx._currentValue=ee)}var re,Ze;function $(h){if(re===void 0)try{throw Error()}catch(_){var g=_.stack.trim().match(/\n( *(at )?)/);re=g&&g[1]||"",Ze=-1<_.stack.indexOf(`
- at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return`
-`+re+h+Ze}var H=!1;function q(h,g){if(!h||H)return"";H=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var E={DetermineComponentFrameRoot:function(){try{if(g){var gt=function(){throw Error()};if(Object.defineProperty(gt.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(gt,[])}catch(ot){var it=ot}Reflect.construct(h,[],gt)}else{try{gt.call()}catch(ot){it=ot}h.call(gt.prototype)}}else{try{throw Error()}catch(ot){it=ot}(gt=h())&&typeof gt.catch=="function"&>.catch(function(){})}}catch(ot){if(ot&&it&&typeof ot.stack=="string")return[ot.stack,it.stack]}return[null,null]}};E.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var j=Object.getOwnPropertyDescriptor(E.DetermineComponentFrameRoot,"name");j&&j.configurable&&Object.defineProperty(E.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var L=E.DetermineComponentFrameRoot(),J=L[0],me=L[1];if(J&&me){var je=J.split(`
-`),et=me.split(`
-`);for(j=E=0;Ej||je[E]!==et[j]){var ft=`
-`+je[E].replace(" at new "," at ");return h.displayName&&ft.includes("")&&(ft=ft.replace("",h.displayName)),ft}while(1<=E&&0<=j);break}}}finally{H=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?$(_):""}function z(h,g){switch(h.tag){case 26:case 27:case 5:return $(h.type);case 16:return $("Lazy");case 13:return h.child!==g&&g!==null?$("Suspense Fallback"):$("Suspense");case 19:return $("SuspenseList");case 0:case 15:return q(h.type,!1);case 11:return q(h.type.render,!1);case 1:return q(h.type,!0);case 31:return $("Activity");default:return""}}function V(h){try{var g="",_=null;do g+=z(h,_),_=h,h=h.return;while(h);return g}catch(E){return`
-Error generating stack: `+E.message+`
-`+E.stack}}var Z=Object.prototype.hasOwnProperty,Te=t.unstable_scheduleCallback,Ce=t.unstable_cancelCallback,be=t.unstable_shouldYield,B=t.unstable_requestPaint,O=t.unstable_now,oe=t.unstable_getCurrentPriorityLevel,ce=t.unstable_ImmediatePriority,ve=t.unstable_UserBlockingPriority,_e=t.unstable_NormalPriority,He=t.unstable_LowPriority,ze=t.unstable_IdlePriority,nt=t.log,dt=t.unstable_setDisableYieldValue,We=null,fe=null;function rt(h){if(typeof nt=="function"&&dt(h),fe&&typeof fe.setStrictMode=="function")try{fe.setStrictMode(We,h)}catch{}}var qe=Math.clz32?Math.clz32:ne,tt=Math.log,ht=Math.LN2;function ne(h){return h>>>=0,h===0?32:31-(tt(h)/ht|0)|0}var Be=256,ye=262144,Ue=4194304;function Re(h){var g=h&42;if(g!==0)return g;switch(h&-h){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function Ae(h,g,_){var E=h.pendingLanes;if(E===0)return 0;var j=0,L=h.suspendedLanes,J=h.pingedLanes;h=h.warmLanes;var me=E&134217727;return me!==0?(E=me&~L,E!==0?j=Re(E):(J&=me,J!==0?j=Re(J):_||(_=me&~h,_!==0&&(j=Re(_))))):(me=E&~L,me!==0?j=Re(me):J!==0?j=Re(J):_||(_=E&~h,_!==0&&(j=Re(_)))),j===0?0:g!==0&&g!==j&&(g&L)===0&&(L=j&-j,_=g&-g,L>=_||L===32&&(_&4194048)!==0)?g:j}function $e(h,g){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&g)===0}function bt(h,g){switch(h){case 1:case 2:case 4:case 8:case 64:return g+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return g+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Nt(){var h=Ue;return Ue<<=1,(Ue&62914560)===0&&(Ue=4194304),h}function jt(h){for(var g=[],_=0;31>_;_++)g.push(h);return g}function qt(h,g){h.pendingLanes|=g,g!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function Ot(h,g,_,E,j,L){var J=h.pendingLanes;h.pendingLanes=_,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=_,h.entangledLanes&=_,h.errorRecoveryDisabledLanes&=_,h.shellSuspendCounter=0;var me=h.entanglements,je=h.expirationTimes,et=h.hiddenUpdates;for(_=J&~_;0<_;){var ft=31-qe(_),gt=1<"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var ei=/[\n"\\]/g;function Gn(h){return h.replace(ei,function(g){return"\\"+g.charCodeAt(0).toString(16)+" "})}function Do(h,g,_,E,j,L,J,me){h.name="",J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"?h.type=J:h.removeAttribute("type"),g!=null?J==="number"?(g===0&&h.value===""||h.value!=g)&&(h.value=""+yn(g)):h.value!==""+yn(g)&&(h.value=""+yn(g)):J!=="submit"&&J!=="reset"||h.removeAttribute("value"),g!=null?Ii(h,J,yn(g)):_!=null?Ii(h,J,yn(_)):E!=null&&h.removeAttribute("value"),j==null&&L!=null&&(h.defaultChecked=!!L),j!=null&&(h.checked=j&&typeof j!="function"&&typeof j!="symbol"),me!=null&&typeof me!="function"&&typeof me!="symbol"&&typeof me!="boolean"?h.name=""+yn(me):h.removeAttribute("name")}function Yn(h,g,_,E,j,L,J,me){if(L!=null&&typeof L!="function"&&typeof L!="symbol"&&typeof L!="boolean"&&(h.type=L),g!=null||_!=null){if(!(L!=="submit"&&L!=="reset"||g!=null)){an(h);return}_=_!=null?""+yn(_):"",g=g!=null?""+yn(g):_,me||g===h.value||(h.value=g),h.defaultValue=g}E=E??j,E=typeof E!="function"&&typeof E!="symbol"&&!!E,h.checked=me?h.checked:!!E,h.defaultChecked=!!E,J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"&&(h.name=J),an(h)}function Ii(h,g,_){g==="number"&&$r(h.ownerDocument)===h||h.defaultValue===""+_||(h.defaultValue=""+_)}function wi(h,g,_,E){if(h=h.options,g){g={};for(var j=0;j<_.length;j++)g["$"+_[j]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),gE=!1;if(Zl)try{var ex={};Object.defineProperty(ex,"passive",{get:function(){gE=!0}}),window.addEventListener("test",ex,ex),window.removeEventListener("test",ex,ex)}catch{gE=!1}var gu=null,xE=null,Iy=null;function IL(){if(Iy)return Iy;var h,g=xE,_=g.length,E,j="value"in gu?gu.value:gu.textContent,L=j.length;for(h=0;h<_&&g[h]===j[h];h++);var J=_-h;for(E=1;E<=J&&g[_-E]===j[L-E];E++);return Iy=j.slice(h,1=rx),HL=" ",VL=!1;function GL(h,g){switch(h){case"keyup":return Vre.indexOf(g.keyCode)!==-1;case"keydown":return g.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function WL(h){return h=h.detail,typeof h=="object"&&"data"in h?h.data:null}var Jf=!1;function Wre(h,g){switch(h){case"compositionend":return WL(g);case"keypress":return g.which!==32?null:(VL=!0,HL);case"textInput":return h=g.data,h===HL&&VL?null:h;default:return null}}function qre(h,g){if(Jf)return h==="compositionend"||!wE&&GL(h,g)?(h=IL(),Iy=xE=gu=null,Jf=!1,h):null;switch(h){case"paste":return null;case"keypress":if(!(g.ctrlKey||g.altKey||g.metaKey)||g.ctrlKey&&g.altKey){if(g.char&&1=g)return{node:_,offset:g-h};h=E}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=eI(_)}}function nI(h,g){return h&&g?h===g?!0:h&&h.nodeType===3?!1:g&&g.nodeType===3?nI(h,g.parentNode):"contains"in h?h.contains(g):h.compareDocumentPosition?!!(h.compareDocumentPosition(g)&16):!1:!1}function rI(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var g=$r(h.document);g instanceof h.HTMLIFrameElement;){try{var _=typeof g.contentWindow.location.href=="string"}catch{_=!1}if(_)h=g.contentWindow;else break;g=$r(h.document)}return g}function EE(h){var g=h&&h.nodeName&&h.nodeName.toLowerCase();return g&&(g==="input"&&(h.type==="text"||h.type==="search"||h.type==="tel"||h.type==="url"||h.type==="password")||g==="textarea"||h.contentEditable==="true")}var tie=Zl&&"documentMode"in document&&11>=document.documentMode,em=null,NE=null,ox=null,AE=!1;function iI(h,g,_){var E=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;AE||em==null||em!==$r(E)||(E=em,"selectionStart"in E&&EE(E)?E={start:E.selectionStart,end:E.selectionEnd}:(E=(E.ownerDocument&&E.ownerDocument.defaultView||window).getSelection(),E={anchorNode:E.anchorNode,anchorOffset:E.anchorOffset,focusNode:E.focusNode,focusOffset:E.focusOffset}),ox&&sx(ox,E)||(ox=E,E=kv(NE,"onSelect"),0>=J,j-=J,cl=1<<32-qe(g)+j|_<cn?(kn=Ut,Ut=null):kn=Ut.sibling;var In=it(Ge,Ut,Je[cn],pt);if(In===null){Ut===null&&(Ut=kn);break}h&&Ut&&In.alternate===null&&g(Ge,Ut),Le=L(In,Le,cn),Ln===null?Ht=In:Ln.sibling=In,Ln=In,Ut=kn}if(cn===Je.length)return _(Ge,Ut),Pn&&ec(Ge,cn),Ht;if(Ut===null){for(;cncn?(kn=Ut,Ut=null):kn=Ut.sibling;var Fu=it(Ge,Ut,In.value,pt);if(Fu===null){Ut===null&&(Ut=kn);break}h&&Ut&&Fu.alternate===null&&g(Ge,Ut),Le=L(Fu,Le,cn),Ln===null?Ht=Fu:Ln.sibling=Fu,Ln=Fu,Ut=kn}if(In.done)return _(Ge,Ut),Pn&&ec(Ge,cn),Ht;if(Ut===null){for(;!In.done;cn++,In=Je.next())In=gt(Ge,In.value,pt),In!==null&&(Le=L(In,Le,cn),Ln===null?Ht=In:Ln.sibling=In,Ln=In);return Pn&&ec(Ge,cn),Ht}for(Ut=E(Ut);!In.done;cn++,In=Je.next())In=ot(Ut,Ge,cn,In.value,pt),In!==null&&(h&&In.alternate!==null&&Ut.delete(In.key===null?cn:In.key),Le=L(In,Le,cn),Ln===null?Ht=In:Ln.sibling=In,Ln=In);return h&&Ut.forEach(function(_ae){return g(Ge,_ae)}),Pn&&ec(Ge,cn),Ht}function ar(Ge,Le,Je,pt){if(typeof Je=="object"&&Je!==null&&Je.type===y&&Je.key===null&&(Je=Je.props.children),typeof Je=="object"&&Je!==null){switch(Je.$$typeof){case p:e:{for(var Ht=Je.key;Le!==null;){if(Le.key===Ht){if(Ht=Je.type,Ht===y){if(Le.tag===7){_(Ge,Le.sibling),pt=j(Le,Je.props.children),pt.return=Ge,Ge=pt;break e}}else if(Le.elementType===Ht||typeof Ht=="object"&&Ht!==null&&Ht.$$typeof===R&&dh(Ht)===Le.type){_(Ge,Le.sibling),pt=j(Le,Je.props),fx(pt,Je),pt.return=Ge,Ge=pt;break e}_(Ge,Le);break}else g(Ge,Le);Le=Le.sibling}Je.type===y?(pt=sh(Je.props.children,Ge.mode,pt,Je.key),pt.return=Ge,Ge=pt):(pt=qy(Je.type,Je.key,Je.props,null,Ge.mode,pt),fx(pt,Je),pt.return=Ge,Ge=pt)}return J(Ge);case b:e:{for(Ht=Je.key;Le!==null;){if(Le.key===Ht)if(Le.tag===4&&Le.stateNode.containerInfo===Je.containerInfo&&Le.stateNode.implementation===Je.implementation){_(Ge,Le.sibling),pt=j(Le,Je.children||[]),pt.return=Ge,Ge=pt;break e}else{_(Ge,Le);break}else g(Ge,Le);Le=Le.sibling}pt=DE(Je,Ge.mode,pt),pt.return=Ge,Ge=pt}return J(Ge);case R:return Je=dh(Je),ar(Ge,Le,Je,pt)}if(K(Je))return It(Ge,Le,Je,pt);if(I(Je)){if(Ht=I(Je),typeof Ht!="function")throw Error(r(150));return Je=Ht.call(Je),Kt(Ge,Le,Je,pt)}if(typeof Je.then=="function")return ar(Ge,Le,ev(Je),pt);if(Je.$$typeof===S)return ar(Ge,Le,Yy(Ge,Je),pt);tv(Ge,Je)}return typeof Je=="string"&&Je!==""||typeof Je=="number"||typeof Je=="bigint"?(Je=""+Je,Le!==null&&Le.tag===6?(_(Ge,Le.sibling),pt=j(Le,Je),pt.return=Ge,Ge=pt):(_(Ge,Le),pt=RE(Je,Ge.mode,pt),pt.return=Ge,Ge=pt),J(Ge)):_(Ge,Le)}return function(Ge,Le,Je,pt){try{hx=0;var Ht=ar(Ge,Le,Je,pt);return dm=null,Ht}catch(Ut){if(Ut===um||Ut===Zy)throw Ut;var Ln=_s(29,Ut,null,Ge.mode);return Ln.lanes=pt,Ln.return=Ge,Ln}finally{}}}var fh=AI(!0),TI=AI(!1),_u=!1;function WE(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function qE(h,g){h=h.updateQueue,g.updateQueue===h&&(g.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function wu(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function Su(h,g,_){var E=h.updateQueue;if(E===null)return null;if(E=E.shared,(Bn&2)!==0){var j=E.pending;return j===null?g.next=g:(g.next=j.next,j.next=g),E.pending=g,g=Wy(h),dI(h,null,_),g}return Gy(h,E,g,_),Wy(h)}function mx(h,g,_){if(g=g.updateQueue,g!==null&&(g=g.shared,(_&4194048)!==0)){var E=g.lanes;E&=h.pendingLanes,_|=E,g.lanes=_,An(h,_)}}function KE(h,g){var _=h.updateQueue,E=h.alternate;if(E!==null&&(E=E.updateQueue,_===E)){var j=null,L=null;if(_=_.firstBaseUpdate,_!==null){do{var J={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};L===null?j=L=J:L=L.next=J,_=_.next}while(_!==null);L===null?j=L=g:L=L.next=g}else j=L=g;_={baseState:E.baseState,firstBaseUpdate:j,lastBaseUpdate:L,shared:E.shared,callbacks:E.callbacks},h.updateQueue=_;return}h=_.lastBaseUpdate,h===null?_.firstBaseUpdate=g:h.next=g,_.lastBaseUpdate=g}var XE=!1;function px(){if(XE){var h=cm;if(h!==null)throw h}}function gx(h,g,_,E){XE=!1;var j=h.updateQueue;_u=!1;var L=j.firstBaseUpdate,J=j.lastBaseUpdate,me=j.shared.pending;if(me!==null){j.shared.pending=null;var je=me,et=je.next;je.next=null,J===null?L=et:J.next=et,J=je;var ft=h.alternate;ft!==null&&(ft=ft.updateQueue,me=ft.lastBaseUpdate,me!==J&&(me===null?ft.firstBaseUpdate=et:me.next=et,ft.lastBaseUpdate=je))}if(L!==null){var gt=j.baseState;J=0,ft=et=je=null,me=L;do{var it=me.lane&-536870913,ot=it!==me.lane;if(ot?(Cn&it)===it:(E&it)===it){it!==0&&it===lm&&(XE=!0),ft!==null&&(ft=ft.next={lane:0,tag:me.tag,payload:me.payload,callback:null,next:null});e:{var It=h,Kt=me;it=g;var ar=_;switch(Kt.tag){case 1:if(It=Kt.payload,typeof It=="function"){gt=It.call(ar,gt,it);break e}gt=It;break e;case 3:It.flags=It.flags&-65537|128;case 0:if(It=Kt.payload,it=typeof It=="function"?It.call(ar,gt,it):It,it==null)break e;gt=f({},gt,it);break e;case 2:_u=!0}}it=me.callback,it!==null&&(h.flags|=64,ot&&(h.flags|=8192),ot=j.callbacks,ot===null?j.callbacks=[it]:ot.push(it))}else ot={lane:it,tag:me.tag,payload:me.payload,callback:me.callback,next:null},ft===null?(et=ft=ot,je=gt):ft=ft.next=ot,J|=it;if(me=me.next,me===null){if(me=j.shared.pending,me===null)break;ot=me,me=ot.next,ot.next=null,j.lastBaseUpdate=ot,j.shared.pending=null}}while(!0);ft===null&&(je=gt),j.baseState=je,j.firstBaseUpdate=et,j.lastBaseUpdate=ft,L===null&&(j.shared.lanes=0),Tu|=J,h.lanes=J,h.memoizedState=gt}}function CI(h,g){if(typeof h!="function")throw Error(r(191,h));h.call(g)}function kI(h,g){var _=h.callbacks;if(_!==null)for(h.callbacks=null,h=0;h<_.length;h++)CI(_[h],g)}var hm=U(null),nv=U(0);function jI(h,g){h=dc,Q(nv,h),Q(hm,g),dc=h|g.baseLanes}function YE(){Q(nv,dc),Q(hm,hm.current)}function QE(){dc=nv.current,G(hm),G(nv)}var ws=U(null),to=null;function Mu(h){var g=h.alternate;Q(ti,ti.current&1),Q(ws,h),to===null&&(g===null||hm.current!==null||g.memoizedState!==null)&&(to=h)}function ZE(h){Q(ti,ti.current),Q(ws,h),to===null&&(to=h)}function PI(h){h.tag===22?(Q(ti,ti.current),Q(ws,h),to===null&&(to=h)):Eu()}function Eu(){Q(ti,ti.current),Q(ws,ws.current)}function Ss(h){G(ws),to===h&&(to=null),G(ti)}var ti=U(0);function rv(h){for(var g=h;g!==null;){if(g.tag===13){var _=g.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||iA(_)||aA(_)))return g}else if(g.tag===19&&(g.memoizedProps.revealOrder==="forwards"||g.memoizedProps.revealOrder==="backwards"||g.memoizedProps.revealOrder==="unstable_legacy-backwards"||g.memoizedProps.revealOrder==="together")){if((g.flags&128)!==0)return g}else if(g.child!==null){g.child.return=g,g=g.child;continue}if(g===h)break;for(;g.sibling===null;){if(g.return===null||g.return===h)return null;g=g.return}g.sibling.return=g.return,g=g.sibling}return null}var rc=0,ln=null,rr=null,li=null,iv=!1,fm=!1,mh=!1,av=0,xx=0,mm=null,die=0;function Hr(){throw Error(r(321))}function JE(h,g){if(g===null)return!1;for(var _=0;_L?L:8;var J=Y.T,me={};Y.T=me,mN(h,!1,g,_);try{var je=j(),et=Y.S;if(et!==null&&et(me,je),je!==null&&typeof je=="object"&&typeof je.then=="function"){var ft=uie(je,E);yx(h,g,ft,Ns(h))}else yx(h,g,E,Ns(h))}catch(gt){yx(h,g,{then:function(){},status:"rejected",reason:gt},Ns())}finally{W.p=L,J!==null&&me.types!==null&&(J.types=me.types),Y.T=J}}function gie(){}function hN(h,g,_,E){if(h.tag!==5)throw Error(r(476));var j=l5(h).queue;o5(h,j,g,ee,_===null?gie:function(){return c5(h),_(E)})}function l5(h){var g=h.memoizedState;if(g!==null)return g;g={memoizedState:ee,baseState:ee,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ic,lastRenderedState:ee},next:null};var _={};return g.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:ic,lastRenderedState:_},next:null},h.memoizedState=g,h=h.alternate,h!==null&&(h.memoizedState=g),g}function c5(h){var g=l5(h);g.next===null&&(g=h.alternate.memoizedState),yx(h,g.next.queue,{},Ns())}function fN(){return ia(Lx)}function u5(){return ni().memoizedState}function d5(){return ni().memoizedState}function xie(h){for(var g=h.return;g!==null;){switch(g.tag){case 24:case 3:var _=Ns();h=wu(_);var E=Su(g,h,_);E!==null&&(ts(E,g,_),mx(E,g,_)),g={cache:$E()},h.payload=g;return}g=g.return}}function bie(h,g,_){var E=Ns();_={lane:E,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},dv(h)?f5(g,_):(_=jE(h,g,_,E),_!==null&&(ts(_,h,E),m5(_,g,E)))}function h5(h,g,_){var E=Ns();yx(h,g,_,E)}function yx(h,g,_,E){var j={lane:E,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(dv(h))f5(g,j);else{var L=h.alternate;if(h.lanes===0&&(L===null||L.lanes===0)&&(L=g.lastRenderedReducer,L!==null))try{var J=g.lastRenderedState,me=L(J,_);if(j.hasEagerState=!0,j.eagerState=me,vs(me,J))return Gy(h,g,j,0),mr===null&&Vy(),!1}catch{}finally{}if(_=jE(h,g,j,E),_!==null)return ts(_,h,E),m5(_,g,E),!0}return!1}function mN(h,g,_,E){if(E={lane:2,revertLane:WN(),gesture:null,action:E,hasEagerState:!1,eagerState:null,next:null},dv(h)){if(g)throw Error(r(479))}else g=jE(h,_,E,2),g!==null&&ts(g,h,2)}function dv(h){var g=h.alternate;return h===ln||g!==null&&g===ln}function f5(h,g){fm=iv=!0;var _=h.pending;_===null?g.next=g:(g.next=_.next,_.next=g),h.pending=g}function m5(h,g,_){if((_&4194048)!==0){var E=g.lanes;E&=h.pendingLanes,_|=E,g.lanes=_,An(h,_)}}var vx={readContext:ia,use:ov,useCallback:Hr,useContext:Hr,useEffect:Hr,useImperativeHandle:Hr,useLayoutEffect:Hr,useInsertionEffect:Hr,useMemo:Hr,useReducer:Hr,useRef:Hr,useState:Hr,useDebugValue:Hr,useDeferredValue:Hr,useTransition:Hr,useSyncExternalStore:Hr,useId:Hr,useHostTransitionStatus:Hr,useFormState:Hr,useActionState:Hr,useOptimistic:Hr,useMemoCache:Hr,useCacheRefresh:Hr};vx.useEffectEvent=Hr;var p5={readContext:ia,use:ov,useCallback:function(h,g){return Da().memoizedState=[h,g===void 0?null:g],h},useContext:ia,useEffect:ZI,useImperativeHandle:function(h,g,_){_=_!=null?_.concat([h]):null,cv(4194308,4,n5.bind(null,g,h),_)},useLayoutEffect:function(h,g){return cv(4194308,4,h,g)},useInsertionEffect:function(h,g){cv(4,2,h,g)},useMemo:function(h,g){var _=Da();g=g===void 0?null:g;var E=h();if(mh){rt(!0);try{h()}finally{rt(!1)}}return _.memoizedState=[E,g],E},useReducer:function(h,g,_){var E=Da();if(_!==void 0){var j=_(g);if(mh){rt(!0);try{_(g)}finally{rt(!1)}}}else j=g;return E.memoizedState=E.baseState=j,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:j},E.queue=h,h=h.dispatch=bie.bind(null,ln,h),[E.memoizedState,h]},useRef:function(h){var g=Da();return h={current:h},g.memoizedState=h},useState:function(h){h=oN(h);var g=h.queue,_=h5.bind(null,ln,g);return g.dispatch=_,[h.memoizedState,_]},useDebugValue:uN,useDeferredValue:function(h,g){var _=Da();return dN(_,h,g)},useTransition:function(){var h=oN(!1);return h=o5.bind(null,ln,h.queue,!0,!1),Da().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,g,_){var E=ln,j=Da();if(Pn){if(_===void 0)throw Error(r(407));_=_()}else{if(_=g(),mr===null)throw Error(r(349));(Cn&127)!==0||LI(E,g,_)}j.memoizedState=_;var L={value:_,getSnapshot:g};return j.queue=L,ZI(FI.bind(null,E,L,h),[h]),E.flags|=2048,pm(9,{destroy:void 0},II.bind(null,E,L,_,g),null),_},useId:function(){var h=Da(),g=mr.identifierPrefix;if(Pn){var _=ul,E=cl;_=(E&~(1<<32-qe(E)-1)).toString(32)+_,g="_"+g+"R_"+_,_=av++,0<_&&(g+="H"+_.toString(32)),g+="_"}else _=die++,g="_"+g+"r_"+_.toString(32)+"_";return h.memoizedState=g},useHostTransitionStatus:fN,useFormState:qI,useActionState:qI,useOptimistic:function(h){var g=Da();g.memoizedState=g.baseState=h;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return g.queue=_,g=mN.bind(null,ln,!0,_),_.dispatch=g,[h,g]},useMemoCache:iN,useCacheRefresh:function(){return Da().memoizedState=xie.bind(null,ln)},useEffectEvent:function(h){var g=Da(),_={impl:h};return g.memoizedState=_,function(){if((Bn&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},pN={readContext:ia,use:ov,useCallback:i5,useContext:ia,useEffect:cN,useImperativeHandle:r5,useInsertionEffect:e5,useLayoutEffect:t5,useMemo:a5,useReducer:lv,useRef:QI,useState:function(){return lv(ic)},useDebugValue:uN,useDeferredValue:function(h,g){var _=ni();return s5(_,rr.memoizedState,h,g)},useTransition:function(){var h=lv(ic)[0],g=ni().memoizedState;return[typeof h=="boolean"?h:bx(h),g]},useSyncExternalStore:OI,useId:u5,useHostTransitionStatus:fN,useFormState:KI,useActionState:KI,useOptimistic:function(h,g){var _=ni();return BI(_,rr,h,g)},useMemoCache:iN,useCacheRefresh:d5};pN.useEffectEvent=JI;var g5={readContext:ia,use:ov,useCallback:i5,useContext:ia,useEffect:cN,useImperativeHandle:r5,useInsertionEffect:e5,useLayoutEffect:t5,useMemo:a5,useReducer:sN,useRef:QI,useState:function(){return sN(ic)},useDebugValue:uN,useDeferredValue:function(h,g){var _=ni();return rr===null?dN(_,h,g):s5(_,rr.memoizedState,h,g)},useTransition:function(){var h=sN(ic)[0],g=ni().memoizedState;return[typeof h=="boolean"?h:bx(h),g]},useSyncExternalStore:OI,useId:u5,useHostTransitionStatus:fN,useFormState:YI,useActionState:YI,useOptimistic:function(h,g){var _=ni();return rr!==null?BI(_,rr,h,g):(_.baseState=h,[h,_.queue.dispatch])},useMemoCache:iN,useCacheRefresh:d5};g5.useEffectEvent=JI;function gN(h,g,_,E){g=h.memoizedState,_=_(E,g),_=_==null?g:f({},g,_),h.memoizedState=_,h.lanes===0&&(h.updateQueue.baseState=_)}var xN={enqueueSetState:function(h,g,_){h=h._reactInternals;var E=Ns(),j=wu(E);j.payload=g,_!=null&&(j.callback=_),g=Su(h,j,E),g!==null&&(ts(g,h,E),mx(g,h,E))},enqueueReplaceState:function(h,g,_){h=h._reactInternals;var E=Ns(),j=wu(E);j.tag=1,j.payload=g,_!=null&&(j.callback=_),g=Su(h,j,E),g!==null&&(ts(g,h,E),mx(g,h,E))},enqueueForceUpdate:function(h,g){h=h._reactInternals;var _=Ns(),E=wu(_);E.tag=2,g!=null&&(E.callback=g),g=Su(h,E,_),g!==null&&(ts(g,h,_),mx(g,h,_))}};function x5(h,g,_,E,j,L,J){return h=h.stateNode,typeof h.shouldComponentUpdate=="function"?h.shouldComponentUpdate(E,L,J):g.prototype&&g.prototype.isPureReactComponent?!sx(_,E)||!sx(j,L):!0}function b5(h,g,_,E){h=g.state,typeof g.componentWillReceiveProps=="function"&&g.componentWillReceiveProps(_,E),typeof g.UNSAFE_componentWillReceiveProps=="function"&&g.UNSAFE_componentWillReceiveProps(_,E),g.state!==h&&xN.enqueueReplaceState(g,g.state,null)}function ph(h,g){var _=g;if("ref"in g){_={};for(var E in g)E!=="ref"&&(_[E]=g[E])}if(h=h.defaultProps){_===g&&(_=f({},_));for(var j in h)_[j]===void 0&&(_[j]=h[j])}return _}function y5(h){Hy(h)}function v5(h){console.error(h)}function _5(h){Hy(h)}function hv(h,g){try{var _=h.onUncaughtError;_(g.value,{componentStack:g.stack})}catch(E){setTimeout(function(){throw E})}}function w5(h,g,_){try{var E=h.onCaughtError;E(_.value,{componentStack:_.stack,errorBoundary:g.tag===1?g.stateNode:null})}catch(j){setTimeout(function(){throw j})}}function bN(h,g,_){return _=wu(_),_.tag=3,_.payload={element:null},_.callback=function(){hv(h,g)},_}function S5(h){return h=wu(h),h.tag=3,h}function M5(h,g,_,E){var j=_.type.getDerivedStateFromError;if(typeof j=="function"){var L=E.value;h.payload=function(){return j(L)},h.callback=function(){w5(g,_,E)}}var J=_.stateNode;J!==null&&typeof J.componentDidCatch=="function"&&(h.callback=function(){w5(g,_,E),typeof j!="function"&&(Cu===null?Cu=new Set([this]):Cu.add(this));var me=E.stack;this.componentDidCatch(E.value,{componentStack:me!==null?me:""})})}function yie(h,g,_,E,j){if(_.flags|=32768,E!==null&&typeof E=="object"&&typeof E.then=="function"){if(g=_.alternate,g!==null&&om(g,_,j,!0),_=ws.current,_!==null){switch(_.tag){case 31:case 13:return to===null?Mv():_.alternate===null&&Vr===0&&(Vr=3),_.flags&=-257,_.flags|=65536,_.lanes=j,E===Jy?_.flags|=16384:(g=_.updateQueue,g===null?_.updateQueue=new Set([E]):g.add(E),HN(h,E,j)),!1;case 22:return _.flags|=65536,E===Jy?_.flags|=16384:(g=_.updateQueue,g===null?(g={transitions:null,markerInstances:null,retryQueue:new Set([E])},_.updateQueue=g):(_=g.retryQueue,_===null?g.retryQueue=new Set([E]):_.add(E)),HN(h,E,j)),!1}throw Error(r(435,_.tag))}return HN(h,E,j),Mv(),!1}if(Pn)return g=ws.current,g!==null?((g.flags&65536)===0&&(g.flags|=256),g.flags|=65536,g.lanes=j,E!==IE&&(h=Error(r(422),{cause:E}),cx(Qs(h,_)))):(E!==IE&&(g=Error(r(423),{cause:E}),cx(Qs(g,_))),h=h.current.alternate,h.flags|=65536,j&=-j,h.lanes|=j,E=Qs(E,_),j=bN(h.stateNode,E,j),KE(h,j),Vr!==4&&(Vr=2)),!1;var L=Error(r(520),{cause:E});if(L=Qs(L,_),Tx===null?Tx=[L]:Tx.push(L),Vr!==4&&(Vr=2),g===null)return!0;E=Qs(E,_),_=g;do{switch(_.tag){case 3:return _.flags|=65536,h=j&-j,_.lanes|=h,h=bN(_.stateNode,E,h),KE(_,h),!1;case 1:if(g=_.type,L=_.stateNode,(_.flags&128)===0&&(typeof g.getDerivedStateFromError=="function"||L!==null&&typeof L.componentDidCatch=="function"&&(Cu===null||!Cu.has(L))))return _.flags|=65536,j&=-j,_.lanes|=j,j=S5(j),M5(j,h,_,E),KE(_,j),!1}_=_.return}while(_!==null);return!1}var yN=Error(r(461)),ci=!1;function aa(h,g,_,E){g.child=h===null?TI(g,null,_,E):fh(g,h.child,_,E)}function E5(h,g,_,E,j){_=_.render;var L=g.ref;if("ref"in E){var J={};for(var me in E)me!=="ref"&&(J[me]=E[me])}else J=E;return ch(g),E=eN(h,g,_,J,L,j),me=tN(),h!==null&&!ci?(nN(h,g,j),ac(h,g,j)):(Pn&&me&&OE(g),g.flags|=1,aa(h,g,E,j),g.child)}function N5(h,g,_,E,j){if(h===null){var L=_.type;return typeof L=="function"&&!PE(L)&&L.defaultProps===void 0&&_.compare===null?(g.tag=15,g.type=L,A5(h,g,L,E,j)):(h=qy(_.type,null,E,g,g.mode,j),h.ref=g.ref,h.return=g,g.child=h)}if(L=h.child,!AN(h,j)){var J=L.memoizedProps;if(_=_.compare,_=_!==null?_:sx,_(J,E)&&h.ref===g.ref)return ac(h,g,j)}return g.flags|=1,h=Jl(L,E),h.ref=g.ref,h.return=g,g.child=h}function A5(h,g,_,E,j){if(h!==null){var L=h.memoizedProps;if(sx(L,E)&&h.ref===g.ref)if(ci=!1,g.pendingProps=E=L,AN(h,j))(h.flags&131072)!==0&&(ci=!0);else return g.lanes=h.lanes,ac(h,g,j)}return vN(h,g,_,E,j)}function T5(h,g,_,E){var j=E.children,L=h!==null?h.memoizedState:null;if(h===null&&g.stateNode===null&&(g.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),E.mode==="hidden"){if((g.flags&128)!==0){if(L=L!==null?L.baseLanes|_:_,h!==null){for(E=g.child=h.child,j=0;E!==null;)j=j|E.lanes|E.childLanes,E=E.sibling;E=j&~L}else E=0,g.child=null;return C5(h,g,L,_,E)}if((_&536870912)!==0)g.memoizedState={baseLanes:0,cachePool:null},h!==null&&Qy(g,L!==null?L.cachePool:null),L!==null?jI(g,L):YE(),PI(g);else return E=g.lanes=536870912,C5(h,g,L!==null?L.baseLanes|_:_,_,E)}else L!==null?(Qy(g,L.cachePool),jI(g,L),Eu(),g.memoizedState=null):(h!==null&&Qy(g,null),YE(),Eu());return aa(h,g,j,_),g.child}function _x(h,g){return h!==null&&h.tag===22||g.stateNode!==null||(g.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),g.sibling}function C5(h,g,_,E,j){var L=VE();return L=L===null?null:{parent:oi._currentValue,pool:L},g.memoizedState={baseLanes:_,cachePool:L},h!==null&&Qy(g,null),YE(),PI(g),h!==null&&om(h,g,E,!0),g.childLanes=j,null}function fv(h,g){return g=pv({mode:g.mode,children:g.children},h.mode),g.ref=h.ref,h.child=g,g.return=h,g}function k5(h,g,_){return fh(g,h.child,null,_),h=fv(g,g.pendingProps),h.flags|=2,Ss(g),g.memoizedState=null,h}function vie(h,g,_){var E=g.pendingProps,j=(g.flags&128)!==0;if(g.flags&=-129,h===null){if(Pn){if(E.mode==="hidden")return h=fv(g,E),g.lanes=536870912,_x(null,h);if(ZE(g),(h=Sr)?(h=$F(h,eo),h=h!==null&&h.data==="&"?h:null,h!==null&&(g.memoizedState={dehydrated:h,treeContext:xu!==null?{id:cl,overflow:ul}:null,retryLane:536870912,hydrationErrors:null},_=fI(h),_.return=g,g.child=_,ra=g,Sr=null)):h=null,h===null)throw yu(g);return g.lanes=536870912,null}return fv(g,E)}var L=h.memoizedState;if(L!==null){var J=L.dehydrated;if(ZE(g),j)if(g.flags&256)g.flags&=-257,g=k5(h,g,_);else if(g.memoizedState!==null)g.child=h.child,g.flags|=128,g=null;else throw Error(r(558));else if(ci||om(h,g,_,!1),j=(_&h.childLanes)!==0,ci||j){if(E=mr,E!==null&&(J=Kn(E,_),J!==0&&J!==L.retryLane))throw L.retryLane=J,ah(h,J),ts(E,h,J),yN;Mv(),g=k5(h,g,_)}else h=L.treeContext,Sr=no(J.nextSibling),ra=g,Pn=!0,bu=null,eo=!1,h!==null&&gI(g,h),g=fv(g,E),g.flags|=4096;return g}return h=Jl(h.child,{mode:E.mode,children:E.children}),h.ref=g.ref,g.child=h,h.return=g,h}function mv(h,g){var _=g.ref;if(_===null)h!==null&&h.ref!==null&&(g.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(h===null||h.ref!==_)&&(g.flags|=4194816)}}function vN(h,g,_,E,j){return ch(g),_=eN(h,g,_,E,void 0,j),E=tN(),h!==null&&!ci?(nN(h,g,j),ac(h,g,j)):(Pn&&E&&OE(g),g.flags|=1,aa(h,g,_,j),g.child)}function j5(h,g,_,E,j,L){return ch(g),g.updateQueue=null,_=DI(g,E,_,j),RI(h),E=tN(),h!==null&&!ci?(nN(h,g,L),ac(h,g,L)):(Pn&&E&&OE(g),g.flags|=1,aa(h,g,_,L),g.child)}function P5(h,g,_,E,j){if(ch(g),g.stateNode===null){var L=rm,J=_.contextType;typeof J=="object"&&J!==null&&(L=ia(J)),L=new _(E,L),g.memoizedState=L.state!==null&&L.state!==void 0?L.state:null,L.updater=xN,g.stateNode=L,L._reactInternals=g,L=g.stateNode,L.props=E,L.state=g.memoizedState,L.refs={},WE(g),J=_.contextType,L.context=typeof J=="object"&&J!==null?ia(J):rm,L.state=g.memoizedState,J=_.getDerivedStateFromProps,typeof J=="function"&&(gN(g,_,J,E),L.state=g.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof L.getSnapshotBeforeUpdate=="function"||typeof L.UNSAFE_componentWillMount!="function"&&typeof L.componentWillMount!="function"||(J=L.state,typeof L.componentWillMount=="function"&&L.componentWillMount(),typeof L.UNSAFE_componentWillMount=="function"&&L.UNSAFE_componentWillMount(),J!==L.state&&xN.enqueueReplaceState(L,L.state,null),gx(g,E,L,j),px(),L.state=g.memoizedState),typeof L.componentDidMount=="function"&&(g.flags|=4194308),E=!0}else if(h===null){L=g.stateNode;var me=g.memoizedProps,je=ph(_,me);L.props=je;var et=L.context,ft=_.contextType;J=rm,typeof ft=="object"&&ft!==null&&(J=ia(ft));var gt=_.getDerivedStateFromProps;ft=typeof gt=="function"||typeof L.getSnapshotBeforeUpdate=="function",me=g.pendingProps!==me,ft||typeof L.UNSAFE_componentWillReceiveProps!="function"&&typeof L.componentWillReceiveProps!="function"||(me||et!==J)&&b5(g,L,E,J),_u=!1;var it=g.memoizedState;L.state=it,gx(g,E,L,j),px(),et=g.memoizedState,me||it!==et||_u?(typeof gt=="function"&&(gN(g,_,gt,E),et=g.memoizedState),(je=_u||x5(g,_,je,E,it,et,J))?(ft||typeof L.UNSAFE_componentWillMount!="function"&&typeof L.componentWillMount!="function"||(typeof L.componentWillMount=="function"&&L.componentWillMount(),typeof L.UNSAFE_componentWillMount=="function"&&L.UNSAFE_componentWillMount()),typeof L.componentDidMount=="function"&&(g.flags|=4194308)):(typeof L.componentDidMount=="function"&&(g.flags|=4194308),g.memoizedProps=E,g.memoizedState=et),L.props=E,L.state=et,L.context=J,E=je):(typeof L.componentDidMount=="function"&&(g.flags|=4194308),E=!1)}else{L=g.stateNode,qE(h,g),J=g.memoizedProps,ft=ph(_,J),L.props=ft,gt=g.pendingProps,it=L.context,et=_.contextType,je=rm,typeof et=="object"&&et!==null&&(je=ia(et)),me=_.getDerivedStateFromProps,(et=typeof me=="function"||typeof L.getSnapshotBeforeUpdate=="function")||typeof L.UNSAFE_componentWillReceiveProps!="function"&&typeof L.componentWillReceiveProps!="function"||(J!==gt||it!==je)&&b5(g,L,E,je),_u=!1,it=g.memoizedState,L.state=it,gx(g,E,L,j),px();var ot=g.memoizedState;J!==gt||it!==ot||_u||h!==null&&h.dependencies!==null&&Xy(h.dependencies)?(typeof me=="function"&&(gN(g,_,me,E),ot=g.memoizedState),(ft=_u||x5(g,_,ft,E,it,ot,je)||h!==null&&h.dependencies!==null&&Xy(h.dependencies))?(et||typeof L.UNSAFE_componentWillUpdate!="function"&&typeof L.componentWillUpdate!="function"||(typeof L.componentWillUpdate=="function"&&L.componentWillUpdate(E,ot,je),typeof L.UNSAFE_componentWillUpdate=="function"&&L.UNSAFE_componentWillUpdate(E,ot,je)),typeof L.componentDidUpdate=="function"&&(g.flags|=4),typeof L.getSnapshotBeforeUpdate=="function"&&(g.flags|=1024)):(typeof L.componentDidUpdate!="function"||J===h.memoizedProps&&it===h.memoizedState||(g.flags|=4),typeof L.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&&it===h.memoizedState||(g.flags|=1024),g.memoizedProps=E,g.memoizedState=ot),L.props=E,L.state=ot,L.context=je,E=ft):(typeof L.componentDidUpdate!="function"||J===h.memoizedProps&&it===h.memoizedState||(g.flags|=4),typeof L.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&&it===h.memoizedState||(g.flags|=1024),E=!1)}return L=E,mv(h,g),E=(g.flags&128)!==0,L||E?(L=g.stateNode,_=E&&typeof _.getDerivedStateFromError!="function"?null:L.render(),g.flags|=1,h!==null&&E?(g.child=fh(g,h.child,null,j),g.child=fh(g,null,_,j)):aa(h,g,_,j),g.memoizedState=L.state,h=g.child):h=ac(h,g,j),h}function R5(h,g,_,E){return oh(),g.flags|=256,aa(h,g,_,E),g.child}var _N={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function wN(h){return{baseLanes:h,cachePool:wI()}}function SN(h,g,_){return h=h!==null?h.childLanes&~_:0,g&&(h|=Es),h}function D5(h,g,_){var E=g.pendingProps,j=!1,L=(g.flags&128)!==0,J;if((J=L)||(J=h!==null&&h.memoizedState===null?!1:(ti.current&2)!==0),J&&(j=!0,g.flags&=-129),J=(g.flags&32)!==0,g.flags&=-33,h===null){if(Pn){if(j?Mu(g):Eu(),(h=Sr)?(h=$F(h,eo),h=h!==null&&h.data!=="&"?h:null,h!==null&&(g.memoizedState={dehydrated:h,treeContext:xu!==null?{id:cl,overflow:ul}:null,retryLane:536870912,hydrationErrors:null},_=fI(h),_.return=g,g.child=_,ra=g,Sr=null)):h=null,h===null)throw yu(g);return aA(h)?g.lanes=32:g.lanes=536870912,null}var me=E.children;return E=E.fallback,j?(Eu(),j=g.mode,me=pv({mode:"hidden",children:me},j),E=sh(E,j,_,null),me.return=g,E.return=g,me.sibling=E,g.child=me,E=g.child,E.memoizedState=wN(_),E.childLanes=SN(h,J,_),g.memoizedState=_N,_x(null,E)):(Mu(g),MN(g,me))}var je=h.memoizedState;if(je!==null&&(me=je.dehydrated,me!==null)){if(L)g.flags&256?(Mu(g),g.flags&=-257,g=EN(h,g,_)):g.memoizedState!==null?(Eu(),g.child=h.child,g.flags|=128,g=null):(Eu(),me=E.fallback,j=g.mode,E=pv({mode:"visible",children:E.children},j),me=sh(me,j,_,null),me.flags|=2,E.return=g,me.return=g,E.sibling=me,g.child=E,fh(g,h.child,null,_),E=g.child,E.memoizedState=wN(_),E.childLanes=SN(h,J,_),g.memoizedState=_N,g=_x(null,E));else if(Mu(g),aA(me)){if(J=me.nextSibling&&me.nextSibling.dataset,J)var et=J.dgst;J=et,E=Error(r(419)),E.stack="",E.digest=J,cx({value:E,source:null,stack:null}),g=EN(h,g,_)}else if(ci||om(h,g,_,!1),J=(_&h.childLanes)!==0,ci||J){if(J=mr,J!==null&&(E=Kn(J,_),E!==0&&E!==je.retryLane))throw je.retryLane=E,ah(h,E),ts(J,h,E),yN;iA(me)||Mv(),g=EN(h,g,_)}else iA(me)?(g.flags|=192,g.child=h.child,g=null):(h=je.treeContext,Sr=no(me.nextSibling),ra=g,Pn=!0,bu=null,eo=!1,h!==null&&gI(g,h),g=MN(g,E.children),g.flags|=4096);return g}return j?(Eu(),me=E.fallback,j=g.mode,je=h.child,et=je.sibling,E=Jl(je,{mode:"hidden",children:E.children}),E.subtreeFlags=je.subtreeFlags&65011712,et!==null?me=Jl(et,me):(me=sh(me,j,_,null),me.flags|=2),me.return=g,E.return=g,E.sibling=me,g.child=E,_x(null,E),E=g.child,me=h.child.memoizedState,me===null?me=wN(_):(j=me.cachePool,j!==null?(je=oi._currentValue,j=j.parent!==je?{parent:je,pool:je}:j):j=wI(),me={baseLanes:me.baseLanes|_,cachePool:j}),E.memoizedState=me,E.childLanes=SN(h,J,_),g.memoizedState=_N,_x(h.child,E)):(Mu(g),_=h.child,h=_.sibling,_=Jl(_,{mode:"visible",children:E.children}),_.return=g,_.sibling=null,h!==null&&(J=g.deletions,J===null?(g.deletions=[h],g.flags|=16):J.push(h)),g.child=_,g.memoizedState=null,_)}function MN(h,g){return g=pv({mode:"visible",children:g},h.mode),g.return=h,h.child=g}function pv(h,g){return h=_s(22,h,null,g),h.lanes=0,h}function EN(h,g,_){return fh(g,h.child,null,_),h=MN(g,g.pendingProps.children),h.flags|=2,g.memoizedState=null,h}function O5(h,g,_){h.lanes|=g;var E=h.alternate;E!==null&&(E.lanes|=g),zE(h.return,g,_)}function NN(h,g,_,E,j,L){var J=h.memoizedState;J===null?h.memoizedState={isBackwards:g,rendering:null,renderingStartTime:0,last:E,tail:_,tailMode:j,treeForkCount:L}:(J.isBackwards=g,J.rendering=null,J.renderingStartTime=0,J.last=E,J.tail=_,J.tailMode=j,J.treeForkCount=L)}function L5(h,g,_){var E=g.pendingProps,j=E.revealOrder,L=E.tail;E=E.children;var J=ti.current,me=(J&2)!==0;if(me?(J=J&1|2,g.flags|=128):J&=1,Q(ti,J),aa(h,g,E,_),E=Pn?lx:0,!me&&h!==null&&(h.flags&128)!==0)e:for(h=g.child;h!==null;){if(h.tag===13)h.memoizedState!==null&&O5(h,_,g);else if(h.tag===19)O5(h,_,g);else if(h.child!==null){h.child.return=h,h=h.child;continue}if(h===g)break e;for(;h.sibling===null;){if(h.return===null||h.return===g)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}switch(j){case"forwards":for(_=g.child,j=null;_!==null;)h=_.alternate,h!==null&&rv(h)===null&&(j=_),_=_.sibling;_=j,_===null?(j=g.child,g.child=null):(j=_.sibling,_.sibling=null),NN(g,!1,j,_,L,E);break;case"backwards":case"unstable_legacy-backwards":for(_=null,j=g.child,g.child=null;j!==null;){if(h=j.alternate,h!==null&&rv(h)===null){g.child=j;break}h=j.sibling,j.sibling=_,_=j,j=h}NN(g,!0,_,null,L,E);break;case"together":NN(g,!1,null,null,void 0,E);break;default:g.memoizedState=null}return g.child}function ac(h,g,_){if(h!==null&&(g.dependencies=h.dependencies),Tu|=g.lanes,(_&g.childLanes)===0)if(h!==null){if(om(h,g,_,!1),(_&g.childLanes)===0)return null}else return null;if(h!==null&&g.child!==h.child)throw Error(r(153));if(g.child!==null){for(h=g.child,_=Jl(h,h.pendingProps),g.child=_,_.return=g;h.sibling!==null;)h=h.sibling,_=_.sibling=Jl(h,h.pendingProps),_.return=g;_.sibling=null}return g.child}function AN(h,g){return(h.lanes&g)!==0?!0:(h=h.dependencies,!!(h!==null&&Xy(h)))}function _ie(h,g,_){switch(g.tag){case 3:Ne(g,g.stateNode.containerInfo),vu(g,oi,h.memoizedState.cache),oh();break;case 27:case 5:Me(g);break;case 4:Ne(g,g.stateNode.containerInfo);break;case 10:vu(g,g.type,g.memoizedProps.value);break;case 31:if(g.memoizedState!==null)return g.flags|=128,ZE(g),null;break;case 13:var E=g.memoizedState;if(E!==null)return E.dehydrated!==null?(Mu(g),g.flags|=128,null):(_&g.child.childLanes)!==0?D5(h,g,_):(Mu(g),h=ac(h,g,_),h!==null?h.sibling:null);Mu(g);break;case 19:var j=(h.flags&128)!==0;if(E=(_&g.childLanes)!==0,E||(om(h,g,_,!1),E=(_&g.childLanes)!==0),j){if(E)return L5(h,g,_);g.flags|=128}if(j=g.memoizedState,j!==null&&(j.rendering=null,j.tail=null,j.lastEffect=null),Q(ti,ti.current),E)break;return null;case 22:return g.lanes=0,T5(h,g,_,g.pendingProps);case 24:vu(g,oi,h.memoizedState.cache)}return ac(h,g,_)}function I5(h,g,_){if(h!==null)if(h.memoizedProps!==g.pendingProps)ci=!0;else{if(!AN(h,_)&&(g.flags&128)===0)return ci=!1,_ie(h,g,_);ci=(h.flags&131072)!==0}else ci=!1,Pn&&(g.flags&1048576)!==0&&pI(g,lx,g.index);switch(g.lanes=0,g.tag){case 16:e:{var E=g.pendingProps;if(h=dh(g.elementType),g.type=h,typeof h=="function")PE(h)?(E=ph(h,E),g.tag=1,g=P5(null,g,h,E,_)):(g.tag=0,g=vN(null,g,h,E,_));else{if(h!=null){var j=h.$$typeof;if(j===A){g.tag=11,g=E5(null,g,h,E,_);break e}else if(j===N){g.tag=14,g=N5(null,g,h,E,_);break e}}throw g=D(h)||h,Error(r(306,g,""))}}return g;case 0:return vN(h,g,g.type,g.pendingProps,_);case 1:return E=g.type,j=ph(E,g.pendingProps),P5(h,g,E,j,_);case 3:e:{if(Ne(g,g.stateNode.containerInfo),h===null)throw Error(r(387));E=g.pendingProps;var L=g.memoizedState;j=L.element,qE(h,g),gx(g,E,null,_);var J=g.memoizedState;if(E=J.cache,vu(g,oi,E),E!==L.cache&&BE(g,[oi],_,!0),px(),E=J.element,L.isDehydrated)if(L={element:E,isDehydrated:!1,cache:J.cache},g.updateQueue.baseState=L,g.memoizedState=L,g.flags&256){g=R5(h,g,E,_);break e}else if(E!==j){j=Qs(Error(r(424)),g),cx(j),g=R5(h,g,E,_);break e}else{switch(h=g.stateNode.containerInfo,h.nodeType){case 9:h=h.body;break;default:h=h.nodeName==="HTML"?h.ownerDocument.body:h}for(Sr=no(h.firstChild),ra=g,Pn=!0,bu=null,eo=!0,_=TI(g,null,E,_),g.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling}else{if(oh(),E===j){g=ac(h,g,_);break e}aa(h,g,E,_)}g=g.child}return g;case 26:return mv(h,g),h===null?(_=KF(g.type,null,g.pendingProps,null))?g.memoizedState=_:Pn||(_=g.type,h=g.pendingProps,E=jv(ue.current).createElement(_),E[fr]=g,E[xt]=h,sa(E,_,h),Oe(E),g.stateNode=E):g.memoizedState=KF(g.type,h.memoizedProps,g.pendingProps,h.memoizedState),null;case 27:return Me(g),h===null&&Pn&&(E=g.stateNode=GF(g.type,g.pendingProps,ue.current),ra=g,eo=!0,j=Sr,Ru(g.type)?(sA=j,Sr=no(E.firstChild)):Sr=j),aa(h,g,g.pendingProps.children,_),mv(h,g),h===null&&(g.flags|=4194304),g.child;case 5:return h===null&&Pn&&((j=E=Sr)&&(E=Qie(E,g.type,g.pendingProps,eo),E!==null?(g.stateNode=E,ra=g,Sr=no(E.firstChild),eo=!1,j=!0):j=!1),j||yu(g)),Me(g),j=g.type,L=g.pendingProps,J=h!==null?h.memoizedProps:null,E=L.children,tA(j,L)?E=null:J!==null&&tA(j,J)&&(g.flags|=32),g.memoizedState!==null&&(j=eN(h,g,hie,null,null,_),Lx._currentValue=j),mv(h,g),aa(h,g,E,_),g.child;case 6:return h===null&&Pn&&((h=_=Sr)&&(_=Zie(_,g.pendingProps,eo),_!==null?(g.stateNode=_,ra=g,Sr=null,h=!0):h=!1),h||yu(g)),null;case 13:return D5(h,g,_);case 4:return Ne(g,g.stateNode.containerInfo),E=g.pendingProps,h===null?g.child=fh(g,null,E,_):aa(h,g,E,_),g.child;case 11:return E5(h,g,g.type,g.pendingProps,_);case 7:return aa(h,g,g.pendingProps,_),g.child;case 8:return aa(h,g,g.pendingProps.children,_),g.child;case 12:return aa(h,g,g.pendingProps.children,_),g.child;case 10:return E=g.pendingProps,vu(g,g.type,E.value),aa(h,g,E.children,_),g.child;case 9:return j=g.type._context,E=g.pendingProps.children,ch(g),j=ia(j),E=E(j),g.flags|=1,aa(h,g,E,_),g.child;case 14:return N5(h,g,g.type,g.pendingProps,_);case 15:return A5(h,g,g.type,g.pendingProps,_);case 19:return L5(h,g,_);case 31:return vie(h,g,_);case 22:return T5(h,g,_,g.pendingProps);case 24:return ch(g),E=ia(oi),h===null?(j=VE(),j===null&&(j=mr,L=$E(),j.pooledCache=L,L.refCount++,L!==null&&(j.pooledCacheLanes|=_),j=L),g.memoizedState={parent:E,cache:j},WE(g),vu(g,oi,j)):((h.lanes&_)!==0&&(qE(h,g),gx(g,null,null,_),px()),j=h.memoizedState,L=g.memoizedState,j.parent!==E?(j={parent:E,cache:E},g.memoizedState=j,g.lanes===0&&(g.memoizedState=g.updateQueue.baseState=j),vu(g,oi,E)):(E=L.cache,vu(g,oi,E),E!==j.cache&&BE(g,[oi],_,!0))),aa(h,g,g.pendingProps.children,_),g.child;case 29:throw g.pendingProps}throw Error(r(156,g.tag))}function sc(h){h.flags|=4}function TN(h,g,_,E,j){if((g=(h.mode&32)!==0)&&(g=!1),g){if(h.flags|=16777216,(j&335544128)===j)if(h.stateNode.complete)h.flags|=8192;else if(uF())h.flags|=8192;else throw hh=Jy,GE}else h.flags&=-16777217}function F5(h,g){if(g.type!=="stylesheet"||(g.state.loading&4)!==0)h.flags&=-16777217;else if(h.flags|=16777216,!JF(g))if(uF())h.flags|=8192;else throw hh=Jy,GE}function gv(h,g){g!==null&&(h.flags|=4),h.flags&16384&&(g=h.tag!==22?Nt():536870912,h.lanes|=g,ym|=g)}function wx(h,g){if(!Pn)switch(h.tailMode){case"hidden":g=h.tail;for(var _=null;g!==null;)g.alternate!==null&&(_=g),g=g.sibling;_===null?h.tail=null:_.sibling=null;break;case"collapsed":_=h.tail;for(var E=null;_!==null;)_.alternate!==null&&(E=_),_=_.sibling;E===null?g||h.tail===null?h.tail=null:h.tail.sibling=null:E.sibling=null}}function Mr(h){var g=h.alternate!==null&&h.alternate.child===h.child,_=0,E=0;if(g)for(var j=h.child;j!==null;)_|=j.lanes|j.childLanes,E|=j.subtreeFlags&65011712,E|=j.flags&65011712,j.return=h,j=j.sibling;else for(j=h.child;j!==null;)_|=j.lanes|j.childLanes,E|=j.subtreeFlags,E|=j.flags,j.return=h,j=j.sibling;return h.subtreeFlags|=E,h.childLanes=_,g}function wie(h,g,_){var E=g.pendingProps;switch(LE(g),g.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Mr(g),null;case 1:return Mr(g),null;case 3:return _=g.stateNode,E=null,h!==null&&(E=h.memoizedState.cache),g.memoizedState.cache!==E&&(g.flags|=2048),nc(oi),xe(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(h===null||h.child===null)&&(sm(g)?sc(g):h===null||h.memoizedState.isDehydrated&&(g.flags&256)===0||(g.flags|=1024,FE())),Mr(g),null;case 26:var j=g.type,L=g.memoizedState;return h===null?(sc(g),L!==null?(Mr(g),F5(g,L)):(Mr(g),TN(g,j,null,E,_))):L?L!==h.memoizedState?(sc(g),Mr(g),F5(g,L)):(Mr(g),g.flags&=-16777217):(h=h.memoizedProps,h!==E&&sc(g),Mr(g),TN(g,j,h,E,_)),null;case 27:if(Pe(g),_=ue.current,j=g.type,h!==null&&g.stateNode!=null)h.memoizedProps!==E&&sc(g);else{if(!E){if(g.stateNode===null)throw Error(r(166));return Mr(g),null}h=te.current,sm(g)?xI(g):(h=GF(j,E,_),g.stateNode=h,sc(g))}return Mr(g),null;case 5:if(Pe(g),j=g.type,h!==null&&g.stateNode!=null)h.memoizedProps!==E&&sc(g);else{if(!E){if(g.stateNode===null)throw Error(r(166));return Mr(g),null}if(L=te.current,sm(g))xI(g);else{var J=jv(ue.current);switch(L){case 1:L=J.createElementNS("http://www.w3.org/2000/svg",j);break;case 2:L=J.createElementNS("http://www.w3.org/1998/Math/MathML",j);break;default:switch(j){case"svg":L=J.createElementNS("http://www.w3.org/2000/svg",j);break;case"math":L=J.createElementNS("http://www.w3.org/1998/Math/MathML",j);break;case"script":L=J.createElement("div"),L.innerHTML="
-
+
+
From 0c864dc3f4db4581e51e50efd473f9cf86d8c252 Mon Sep 17 00:00:00 2001
From: maziggy
Date: Thu, 22 Jan 2026 10:48:55 +0100
Subject: [PATCH 2/4] =?UTF-8?q?Updated=20Github=20issue=20template=20-=20S?=
=?UTF-8?q?upport=20Package=20section=20-=20A=20markdown=20block=20with=20?=
=?UTF-8?q?instructions=20to=20download=20the=20support=20package=20=20=20?=
=?UTF-8?q?from=20Settings=20=E2=86=92=20System=20Info,=20plus=20a=20link?=
=?UTF-8?q?=20to=20the=20debug=20logging=20guide=20-=20P2S=20added=20-=20A?=
=?UTF-8?q?dded=20to=20the=20printer=20model=20dropdown=20between=20P1P=20?=
=?UTF-8?q?and=20A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.github/ISSUE_TEMPLATE/bug_report.yml | 18 +++++++++++++++---
1 file changed, 15 insertions(+), 3 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 7117a6e1d..e0d25f0dc 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -48,6 +48,7 @@ body:
- X1
- P1S
- P1P
+ - P2S
- A1
- A1 Mini
- H2D
@@ -103,14 +104,25 @@ body:
validations:
required: true
+ - type: markdown
+ attributes:
+ value: |
+ ---
+ ### 📦 Support Package
+
+ For faster debugging, please create and attach a **Support Package** from **Settings → System Info → Download Support Package**.
+ This includes logs, system info, and configuration (with sensitive data redacted).
+
+ For detailed instructions on enabling debug logging, see: [Debug Logging Guide](https://wiki.bambuddy.cool/features/system-info/?h=debug#enable-debug-logging)
+
- type: textarea
id: logs
attributes:
- label: Relevant Logs
- description: Please paste any relevant logs here. Check `logs/bambuddy.log` or use `DEBUG=true` for verbose logging.
+ label: Relevant Logs / Support Package
+ description: Attach a support package (.zip) or paste relevant logs here. Enable DEBUG mode for verbose logging.
render: shell
placeholder: |
- Paste logs here...
+ Drag and drop your support package .zip file here, or paste logs...
- type: textarea
id: screenshots
From 79333045aafcd9cabf0dcae872a8d3ea0f3a63eb Mon Sep 17 00:00:00 2001
From: maziggy
Date: Fri, 23 Jan 2026 08:07:29 +0100
Subject: [PATCH 3/4] Updated README
---
README.md | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index f1e29a296..da8d8d326 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -488,6 +488,10 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
---
+If you like Bambuddy and want to support it, you can buy Martin a coffee .
+
+---
+
## 📄 License
MIT License — see [LICENSE](LICENSE) for details.
From ecd6c3e1528e932f6e96e4b09635d9f2838f6098 Mon Sep 17 00:00:00 2001
From: maziggy
Date: Fri, 23 Jan 2026 08:09:24 +0100
Subject: [PATCH 4/4] Updated README
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index da8d8d326..06cebe55c 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -488,7 +488,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
---
-If you like Bambuddy and want to support it, you can buy Martin a coffee .
+If you like Bambuddy and want to support it, you can buy Martin a coffee .
---