diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 000000000..b50e332f3 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -0,0 +1,213 @@ +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 df5c3d7e7..2f5a86a58 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -8,6 +8,7 @@ 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 @@ -47,6 +48,7 @@ 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 @@ -81,6 +83,7 @@ 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)) @@ -109,6 +112,7 @@ 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 new file mode 100644 index 000000000..4b6c514ac --- /dev/null +++ b/backend/app/api/routes/users.py @@ -0,0 +1,205 @@ +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 5e759ed1b..c30f5881b 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -1,105 +1,350 @@ -import hashlib import secrets -from datetime import datetime +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Annotated -from fastapi import Depends, Header, HTTPException +from fastapi import Depends, Header, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError, jwt +from passlib.context import CryptContext from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from backend.app.core.database import get_db -from backend.app.models.api_key import APIKey +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 def generate_api_key() -> tuple[str, str, str]: """Generate a new API key. Returns: - Tuple of (full_key, key_hash, key_prefix) + 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 """ - # 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 + # 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 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( - x_api_key: str = Header(..., alias="X-API-Key"), + authorization: Annotated[str | None, Header(alias="Authorization")] = None, + x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, db: AsyncSession = Depends(get_db), -) -> APIKey: - """Verify API key and return the key record. +) -> "APIKey": + """Get and validate API key from request headers. - Raises HTTPException if key is invalid, disabled, or expired. + Checks both 'Authorization: Bearer ' and 'X-API-Key: ' headers. """ - key_hash = hash_api_key(x_api_key) + from fastapi import HTTPException, status - result = await db.execute(select(APIKey).where(APIKey.key_hash == key_hash)) - api_key = result.scalar_one_or_none() + from backend.app.models.api_key import APIKey - if not api_key: - raise HTTPException(status_code=401, detail="Invalid API key") + 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.enabled: - raise HTTPException(status_code=403, detail="API key is disabled") + 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 api_key.expires_at and api_key.expires_at < datetime.utcnow(): - raise HTTPException(status_code=403, detail="API key has expired") + # Get all API keys and check them + result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True))) + api_keys = result.scalars().all() - # Update last_used timestamp - api_key.last_used = datetime.utcnow() + 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 - return api_key + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + ) -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. +def check_permission(api_key: "APIKey", permission: str) -> None: + """Check if API key has the required permission. Args: - api_key: The API key record + api_key: The API key object permission: One of 'queue', 'control_printer', 'read_status' - Raises HTTPException if permission is denied. + Raises: + HTTPException: If permission is not granted """ + from fastapi import HTTPException, status + permission_map = { - "queue": api_key.can_queue, - "control_printer": api_key.can_control_printer, - "read_status": api_key.can_read_status, + "queue": "can_queue", + "control_printer": "can_control_printer", + "read_status": "can_read_status", } if permission not in permission_map: - raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Unknown permission: {permission}", + ) - if not permission_map[permission]: - raise HTTPException(status_code=403, detail=f"API key does not have '{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", + ) -def check_printer_access(api_key: APIKey, printer_id: int) -> None: - """Check if API key has access to a specific printer. +def check_printer_access(api_key: "APIKey", printer_id: int) -> None: + """Check if API key has access to the specified printer. Args: - api_key: The API key record - printer_id: The printer ID to check + api_key: The API key object + printer_id: The printer ID to check access for - Raises HTTPException if access is denied. + Raises: + HTTPException: If access is denied """ - 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}") + 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()) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0da968cac..e93070004 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -49,6 +49,7 @@ async def init_db(): project_bom, settings, smart_plug, + user, ) async with engine.begin() as conn: @@ -641,6 +642,25 @@ 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 98b5cc9d3..dfd7fba78 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -54,6 +54,7 @@ from backend.app.api.routes import ( ams_history, api_keys, archives, + auth, camera, cloud, discovery, @@ -75,6 +76,7 @@ from backend.app.api.routes import ( support, system, updates, + users, webhook, websocket, ) @@ -1961,6 +1963,8 @@ 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 25dc630db..5c1290e82 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -12,6 +12,7 @@ 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", @@ -31,4 +32,5 @@ __all__ = [ "PendingUpload", "LibraryFolder", "LibraryFile", + "User", ] diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 000000000..c18958e7a --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,20 @@ +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 new file mode 100644 index 000000000..2b2bc9063 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,47 @@ +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 27508ffab..8b066b982 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -59,6 +59,7 @@ 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 763934a58..86246d9f8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@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", @@ -142,6 +143,7 @@ "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", @@ -492,6 +494,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -514,6 +517,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -541,6 +545,7 @@ "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", @@ -1181,7 +1186,6 @@ "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" } @@ -1190,7 +1194,8 @@ "version": "1.7.4", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "optional": true, + "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" @@ -1200,8 +1205,7 @@ "version": "0.2.10", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@humanfs/core": { "version": "0.19.1", @@ -2308,6 +2312,7 @@ "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" @@ -2556,6 +2561,7 @@ "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" @@ -2661,6 +2667,7 @@ "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" @@ -2687,6 +2694,7 @@ "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" @@ -2701,6 +2709,7 @@ "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", @@ -2799,8 +2808,7 @@ "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, - "peer": true + "dev": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2952,6 +2960,7 @@ "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2961,6 +2970,7 @@ "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" } @@ -2970,6 +2980,7 @@ "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" } @@ -3059,6 +3070,7 @@ "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", @@ -3428,6 +3440,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3612,6 +3625,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -4174,8 +4188,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4367,6 +4380,7 @@ "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", @@ -5054,6 +5068,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -5826,7 +5841,6 @@ "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" } @@ -6315,6 +6329,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -6342,6 +6357,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6373,7 +6389,6 @@ "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", @@ -6388,7 +6403,6 @@ "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" }, @@ -6400,8 +6414,7 @@ "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, - "peer": true + "dev": true }, "node_modules/process-nextick-args": { "version": "2.0.1", @@ -6521,6 +6534,7 @@ "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" } @@ -6550,6 +6564,7 @@ "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", @@ -6598,6 +6613,7 @@ "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", @@ -6628,6 +6644,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6637,6 +6654,7 @@ "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" }, @@ -6683,6 +6701,7 @@ "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" @@ -6811,7 +6830,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -7408,6 +7428,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7546,6 +7567,7 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -8107,6 +8129,7 @@ "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", @@ -8604,6 +8627,7 @@ "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", @@ -9002,6 +9026,7 @@ "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 93f5d2fbb..3adc4e197 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@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 5ec765c64..48cc0f158 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Layout } from './components/Layout'; import { PrintersPage } from './pages/PrintersPage'; @@ -14,9 +14,13 @@ 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: { @@ -32,33 +36,95 @@ 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 ( - - - {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */} - } /> + + + + {/* Setup page - only accessible if auth not enabled */} + } /> - {/* Main app with WebSocket for 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 */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + diff --git a/frontend/src/__tests__/components/Layout.test.tsx b/frontend/src/__tests__/components/Layout.test.tsx index 4395b096d..e63b73e3f 100644 --- a/frontend/src/__tests__/components/Layout.test.tsx +++ b/frontend/src/__tests__/components/Layout.test.tsx @@ -49,6 +49,9 @@ 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 ed7b3b084..9f318f7b6 100644 --- a/frontend/src/__tests__/pages/SettingsPage.test.tsx +++ b/frontend/src/__tests__/pages/SettingsPage.test.tsx @@ -60,6 +60,9 @@ 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 42702c837..29bde1250 100644 --- a/frontend/src/__tests__/utils.tsx +++ b/frontend/src/__tests__/utils.tsx @@ -9,6 +9,7 @@ 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() { @@ -36,7 +37,9 @@ function AllProviders({ children }: AllProvidersProps) { - {children} + + {children} + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 024eb99be..ff07bc04f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,19 +1,47 @@ 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: { - 'Content-Type': 'application/json', - ...options.headers, - }, + 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' @@ -1375,8 +1403,97 @@ 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 d05f90d6c..cf9f3a0c6 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, type LucideIcon } from 'lucide-react'; +import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, type LucideIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; @@ -9,6 +9,7 @@ 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; @@ -68,6 +69,7 @@ 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'; @@ -168,12 +170,20 @@ 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); @@ -182,6 +192,10 @@ 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); @@ -565,6 +579,15 @@ export function Layout() { > {mode === 'dark' ? : } + {authEnabled && user && ( + + )} {/* Bottom row: version */}
@@ -642,6 +665,15 @@ export function Layout() { > {mode === 'dark' ? : } + {authEnabled && user && ( + + )}
)} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 000000000..9429dad9b --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,116 @@ +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 new file mode 100644 index 000000000..f52d43c5e --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,103 @@ +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 +
+

+ Bambuddy Login +

+

+ Sign in to your account +

+
+ +
+
+
+ + setUsername(e.target.value)} + className="block 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 your username" + autoComplete="username" + /> +
+ +
+ + setPassword(e.target.value)} + className="block 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 your password" + autoComplete="current-password" + /> +
+
+ +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index a1a2b0545..7204698ba 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1,7 +1,9 @@ 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 } 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, Users, Lock, Unlock } 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'; @@ -29,8 +31,10 @@ 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, @@ -46,7 +50,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'>('general'); + const [activeTab, setActiveTab] = useState<'general' | 'network' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer' | 'users'>('general'); const [showCreateAPIKey, setShowCreateAPIKey] = useState(false); const [newAPIKeyName, setNewAPIKeyName] = useState(''); const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({ @@ -66,6 +70,7 @@ 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); @@ -474,7 +479,7 @@ export function SettingsPage() { {/* Tab Navigation */} -
+
+
{/* General Tab */} @@ -2866,6 +2885,226 @@ 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 +
  • +
+ + +
+
+ ) : ( +
+
+
+ +
+
+

Authentication Enabled

+

+ Your Bambuddy instance is secured with authentication. +

+
+
+ + {user && ( +
+
+
+

Current User

+

{user.username}

+

+ Role: {user.role} +

+
+
+ {user.role === 'admin' ? 'Admin' : 'User'} +
+
+
+ )} + +
+ + + {user?.role === 'admin' && ( + + )} +
+
+ )} +
+
+
+ + {authEnabled && ( +
+
+

+ + Role Permissions +

+

+ Overview of what each role can do. +

+
+ +
+ + +
+
+ +
+

Admin

+
+
+ +
    +
  • + + Manage printer settings +
  • +
  • + + Create, edit, and delete users +
  • +
  • + + Access all system features +
  • +
+
+
+ + + +
+
+ +
+

User

+
+
+ +
    +
  • + + 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 new file mode 100644 index 000000000..7095d3abc --- /dev/null +++ b/frontend/src/pages/SetupPage.tsx @@ -0,0 +1,161 @@ +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 +
+

+ Bambuddy Setup +

+

+ Configure authentication for your Bambuddy instance +

+
+ +
+
+
+ setAuthEnabled(e.target.checked)} + className="h-4 w-4 text-bambu-green focus:ring-bambu-green border-bambu-dark-tertiary rounded bg-bambu-dark-secondary" + /> + +
+ + {authEnabled && ( +
+
+ + setAdminUsername(e.target.value)} + className="block 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 admin username" + autoComplete="username" + /> +
+ +
+ + setAdminPassword(e.target.value)} + className="block 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 admin password" + minLength={6} + autoComplete="new-password" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="block 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="Confirm admin password" + minLength={6} + autoComplete="new-password" + /> +
+
+ )} +
+ +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/pages/UsersPage.tsx b/frontend/src/pages/UsersPage.tsx new file mode 100644 index 000000000..9f1e8a6a6 --- /dev/null +++ b/frontend/src/pages/UsersPage.tsx @@ -0,0 +1,398 @@ +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 +

+
+ +
+ + {isLoading ? ( +
+ +
+ ) : ( + +
+ + + + + + + + + + + {users.map((user) => ( + + + + + + + ))} + +
+ Username + + Role + + Status + + Actions +
+ {editingUser === user.id ? ( + setFormData({ ...formData, username: e.target.value })} + className="px-3 py-2 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" + /> + ) : ( + user.username + )} + + {editingUser === user.id ? ( + + ) : ( + + {user.role} + + )} + + + {user.is_active ? 'Active' : 'Inactive'} + + + {editingUser === user.id ? ( +
+ + +
+ ) : ( +
+ + {user.id !== currentUser?.id && ( + + )} +
+ )} +
+
+
+ )} + + {/* Create User Modal */} + {showCreateModal && ( +
{ + setShowCreateModal(false); + setFormData({ username: '', password: '', role: 'user' }); + }} + > + e.stopPropagation()} + > + +
+
+ +

Create User

+
+ +
+
+ +
+
+ + 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" + /> +
+
+ + 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} + /> +
+
+ + +
+
+
+ + +
+
+
+
+ )} + + {/* 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 32f1598ae..ceb6da833 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -16,6 +16,7 @@ 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 5f3c6ad73..27238e913 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,6 +37,10 @@ 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 new file mode 100644 index 000000000..238a186c4 --- /dev/null +++ b/static/assets/index-BK0uiRXP.js @@ -0,0 +1,8665 @@ +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=" - + +