Merge pull request #118 from maziggy/revert-117-main

Revert "Added optional authentication and user management"
This commit is contained in:
MartinNYHC
2026-01-21 15:59:13 +01:00
committed by GitHub
30 changed files with 8775 additions and 10808 deletions
-213
View File
@@ -1,213 +0,0 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
authenticate_user,
create_access_token,
get_current_active_user,
get_password_hash,
get_user_by_username,
)
from backend.app.core.database import get_db
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.schemas.auth import LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
router = APIRouter(prefix="/auth", tags=["authentication"])
async def is_auth_enabled(db: AsyncSession) -> bool:
"""Check if authentication is enabled."""
result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
setting = result.scalar_one_or_none()
return setting and setting.value.lower() == "true"
async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
"""Set authentication enabled status."""
from sqlalchemy import func
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(Settings).values(key="auth_enabled", value="true" if enabled else "false")
stmt = stmt.on_conflict_do_update(
index_elements=["key"], set_={"value": "true" if enabled else "false", "updated_at": func.now()}
)
await db.execute(stmt)
# Note: Don't commit here - let get_db handle it or commit explicitly in the route
@router.post("/setup", response_model=SetupResponse)
async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
"""First-time setup: enable/disable authentication and create admin user."""
import logging
logger = logging.getLogger(__name__)
try:
# Check if auth is already configured (prevent re-setup)
result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
existing_setting = result.scalar_one_or_none()
# Check if users exist
user_count_result = await db.execute(select(User))
user_count = len(user_count_result.scalars().all())
if existing_setting and user_count > 0:
# Auth already configured and users exist - prevent re-setup
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Authentication is already configured. Use user management to modify users.",
)
# If auth_enabled is true but no users exist, allow re-setup (recovery scenario)
admin_created = False
if request.auth_enabled:
if not request.admin_username or not request.admin_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Admin username and password are required when enabling authentication",
)
# Check if admin already exists
existing_admin = await get_user_by_username(db, request.admin_username)
if existing_admin:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Admin user already exists",
)
# Create admin user FIRST (before enabling auth)
try:
logger.info(f"Creating admin user: {request.admin_username}")
admin_user = User(
username=request.admin_username,
password_hash=get_password_hash(request.admin_password),
role="admin",
is_active=True,
)
db.add(admin_user)
logger.info(f"Admin user added to session: {request.admin_username}")
admin_created = True
except Exception as e:
await db.rollback()
logger.error(f"Failed to create admin user: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create admin user: {str(e)}",
)
# Set auth enabled and commit everything together
await set_auth_enabled(db, request.auth_enabled)
await db.commit()
if admin_created:
await db.refresh(admin_user)
logger.info(f"Admin user created successfully: {admin_user.id}")
return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
except HTTPException:
raise
except Exception as e:
logger.error(f"Setup error: {e}", exc_info=True)
await db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Setup failed: {str(e)}",
)
@router.get("/status")
async def get_auth_status(db: AsyncSession = Depends(get_db)):
"""Get authentication status (public endpoint)."""
auth_enabled = await is_auth_enabled(db)
return {"auth_enabled": auth_enabled, "requires_setup": not auth_enabled}
@router.post("/disable", response_model=dict)
async def disable_auth(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Disable authentication (admin only)."""
import logging
logger = logging.getLogger(__name__)
# Only admins can disable authentication
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can disable authentication",
)
try:
await set_auth_enabled(db, False)
await db.commit()
logger.info(f"Authentication disabled by admin user: {current_user.username}")
return {"message": "Authentication disabled successfully", "auth_enabled": False}
except Exception as e:
await db.rollback()
logger.error(f"Failed to disable authentication: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to disable authentication: {str(e)}",
)
@router.post("/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Login and get access token."""
# Check if auth is enabled
auth_enabled = await is_auth_enabled(db)
if not auth_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Authentication is not enabled",
)
user = await authenticate_user(db, request.username, request.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
return LoginResponse(
access_token=access_token,
token_type="bearer",
user=UserResponse(
id=user.id,
username=user.username,
role=user.role,
is_active=user.is_active,
created_at=user.created_at.isoformat(),
),
)
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
"""Get current user information."""
return UserResponse(
id=current_user.id,
username=current_user.username,
role=current_user.role,
is_active=current_user.is_active,
created_at=current_user.created_at.isoformat(),
)
@router.post("/logout")
async def logout():
"""Logout (client should discard token)."""
return {"message": "Logged out successfully"}
-4
View File
@@ -8,7 +8,6 @@ from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequireAdminIfAuthEnabled
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.models.printer import Printer
@@ -48,7 +47,6 @@ async def list_printers(db: AsyncSession = Depends(get_db)):
async def create_printer(
printer_data: PrinterCreate,
db: AsyncSession = Depends(get_db),
_current_user=RequireAdminIfAuthEnabled(),
):
"""Add a new printer."""
# Check if serial number already exists
@@ -83,7 +81,6 @@ async def update_printer(
printer_id: int,
printer_data: PrinterUpdate,
db: AsyncSession = Depends(get_db),
_current_user=RequireAdminIfAuthEnabled(),
):
"""Update a printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -112,7 +109,6 @@ async def delete_printer(
printer_id: int,
delete_archives: bool = True,
db: AsyncSession = Depends(get_db),
_current_user=RequireAdminIfAuthEnabled(),
):
"""Delete a printer.
-205
View File
@@ -1,205 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequireAdmin, get_password_hash
from backend.app.core.database import get_db
from backend.app.models.user import User
from backend.app.schemas.auth import UserCreate, UserResponse, UserUpdate
router = APIRouter(prefix="/users", tags=["users"])
@router.get("", response_model=list[UserResponse])
@router.get("/", response_model=list[UserResponse])
async def list_users(
current_user: User = RequireAdmin(),
db: AsyncSession = Depends(get_db),
):
"""List all users (admin only)."""
result = await db.execute(select(User).order_by(User.created_at))
users = result.scalars().all()
return [
UserResponse(
id=user.id,
username=user.username,
role=user.role,
is_active=user.is_active,
created_at=user.created_at.isoformat(),
)
for user in users
]
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
user_data: UserCreate,
current_user: User = RequireAdmin(),
db: AsyncSession = Depends(get_db),
):
"""Create a new user (admin only)."""
# Check if username already exists
existing_user = await db.execute(select(User).where(User.username == user_data.username))
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists",
)
# Validate role
if user_data.role not in ["admin", "user"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role must be 'admin' or 'user'",
)
new_user = User(
username=user_data.username,
password_hash=get_password_hash(user_data.password),
role=user_data.role,
is_active=True,
)
db.add(new_user)
await db.commit()
await db.refresh(new_user)
return UserResponse(
id=new_user.id,
username=new_user.username,
role=new_user.role,
is_active=new_user.is_active,
created_at=new_user.created_at.isoformat(),
)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
current_user: User = RequireAdmin(),
db: AsyncSession = Depends(get_db),
):
"""Get a user by ID (admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserResponse(
id=user.id,
username=user.username,
role=user.role,
is_active=user.is_active,
created_at=user.created_at.isoformat(),
)
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_data: UserUpdate,
current_user: User = RequireAdmin(),
db: AsyncSession = Depends(get_db),
):
"""Update a user (admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Prevent deactivating the last admin
if user_data.is_active is False and user.role == "admin":
admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
admin_count = len(admin_count_result.scalars().all())
if admin_count <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot deactivate the last admin user",
)
# Prevent changing role of last admin
if user_data.role and user_data.role != "admin" and user.role == "admin":
admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
admin_count = len(admin_count_result.scalars().all())
if admin_count <= 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot change role of the last admin user",
)
if user_data.username is not None:
# Check if new username already exists
existing_user = await db.execute(select(User).where(User.username == user_data.username, User.id != user_id))
if existing_user.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already exists",
)
user.username = user_data.username
if user_data.password is not None:
user.password_hash = get_password_hash(user_data.password)
if user_data.role is not None:
if user_data.role not in ["admin", "user"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Role must be 'admin' or 'user'",
)
user.role = user_data.role
if user_data.is_active is not None:
user.is_active = user_data.is_active
await db.commit()
await db.refresh(user)
return UserResponse(
id=user.id,
username=user.username,
role=user.role,
is_active=user.is_active,
created_at=user.created_at.isoformat(),
)
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
current_user: User = RequireAdmin(),
db: AsyncSession = Depends(get_db),
):
"""Delete a user (admin only)."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Prevent deleting the last admin
if user.role == "admin":
admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
admin_count = len(admin_count_result.scalars().all())
if admin_count == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete the last admin user",
)
# Prevent deleting yourself
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account",
)
await db.delete(user)
await db.commit()
+62 -307
View File
@@ -1,350 +1,105 @@
import hashlib
import secrets
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Annotated
from datetime import datetime
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, Header, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.database import async_session, get_db
from backend.app.models.settings import Settings
from backend.app.models.user import User
if TYPE_CHECKING:
from backend.app.models.api_key import APIKey
# Password hashing
# Use pbkdf2_sha256 instead of bcrypt to avoid 72-byte limit and passlib initialization issues
# pbkdf2_sha256 is a secure password hashing algorithm without bcrypt's limitations
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
# JWT settings
SECRET_KEY = "bambuddy-secret-key-change-in-production" # TODO: Move to settings/env
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
# HTTP Bearer token
security = HTTPBearer(auto_error=False)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash.
Uses pbkdf2_sha256 which handles long passwords automatically.
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password.
Uses pbkdf2_sha256 which is secure and has no password length limit.
"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
"""Get a user by username."""
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
"""Authenticate a user by username and password."""
user = await get_user_by_username(db, username)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
if not user.is_active:
return None
return user
async def is_auth_enabled(db: AsyncSession) -> bool:
"""Check if authentication is enabled."""
try:
result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
setting = result.scalar_one_or_none()
return setting and setting.value.lower() == "true"
except Exception:
# If settings table doesn't exist or query fails, assume auth is disabled
return False
async def get_current_user_optional(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
) -> User | None:
"""Get the current authenticated user from JWT token, or None if not authenticated."""
if credentials is None:
return None
try:
token = credentials.credentials
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
except JWTError:
return None
async with async_session() as db:
user = await get_user_by_username(db, username)
if user is None or not user.is_active:
return None
return user
async def get_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> User:
"""Get the current authenticated user from JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
token = credentials.credentials
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
async with async_session() as db:
user = await get_user_by_username(db, username)
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled",
)
return user
async def get_current_active_user(current_user: Annotated[User, Depends(get_current_user)]) -> User:
"""Get the current active user (alias for clarity)."""
return current_user
async def require_auth_if_enabled(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
) -> User | None:
"""Require authentication if auth is enabled, otherwise return None."""
async with async_session() as db:
auth_enabled = await is_auth_enabled(db)
if not auth_enabled:
return None
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
try:
token = credentials.credentials
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = await get_user_by_username(db, username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_role(required_role: str):
"""Dependency factory for role-based access control."""
async def role_checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
if current_user.role != required_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires {required_role} role",
)
return current_user
return role_checker
def require_admin_if_auth_enabled():
"""Dependency factory that requires admin role if auth is enabled."""
async def admin_checker(
current_user: Annotated[User | None, Depends(require_auth_if_enabled)] = None,
) -> User | None:
if current_user is None:
return None # Auth not enabled, allow access
if current_user.role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Requires admin role",
)
return current_user
return admin_checker
from backend.app.core.database import get_db
from backend.app.models.api_key import APIKey
def generate_api_key() -> tuple[str, str, str]:
"""Generate a new API key.
Returns:
tuple: (full_key, key_hash, key_prefix)
- full_key: The complete API key (only shown once on creation)
- key_hash: Hashed version for storage and verification
- key_prefix: First 8 characters for display purposes
Tuple of (full_key, key_hash, key_prefix)
"""
# Generate a secure random API key (32 bytes = 64 hex characters)
full_key = f"bb_{secrets.token_urlsafe(32)}"
key_hash = get_password_hash(full_key)
key_prefix = full_key[:8] + "..." if len(full_key) > 8 else full_key
# Generate a random 32-byte key and encode as hex (64 chars)
full_key = f"bb_{secrets.token_hex(32)}"
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
key_prefix = full_key[:11] # "bb_" + first 8 chars of token
return full_key, key_hash, key_prefix
def hash_api_key(key: str) -> str:
"""Hash an API key for comparison."""
return hashlib.sha256(key.encode()).hexdigest()
async def get_api_key(
authorization: Annotated[str | None, Header(alias="Authorization")] = None,
x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
x_api_key: str = Header(..., alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
) -> "APIKey":
"""Get and validate API key from request headers.
) -> APIKey:
"""Verify API key and return the key record.
Checks both 'Authorization: Bearer <key>' and 'X-API-Key: <key>' headers.
Raises HTTPException if key is invalid, disabled, or expired.
"""
from fastapi import HTTPException, status
key_hash = hash_api_key(x_api_key)
from backend.app.models.api_key import APIKey
result = await db.execute(select(APIKey).where(APIKey.key_hash == key_hash))
api_key = result.scalar_one_or_none()
api_key_value = None
if x_api_key:
api_key_value = x_api_key
elif authorization and authorization.startswith("Bearer "):
api_key_value = authorization.replace("Bearer ", "")
if not api_key:
raise HTTPException(status_code=401, detail="Invalid API key")
if not api_key_value:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
)
if not api_key.enabled:
raise HTTPException(status_code=403, detail="API key is disabled")
# Get all API keys and check them
result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
api_keys = result.scalars().all()
if api_key.expires_at and api_key.expires_at < datetime.utcnow():
raise HTTPException(status_code=403, detail="API key has expired")
for api_key in api_keys:
# Check if key matches (verify against hash)
if verify_password(api_key_value, api_key.key_hash):
# Check expiration
if api_key.expires_at and api_key.expires_at < datetime.now():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="API key has expired",
)
# Update last_used timestamp
api_key.last_used = datetime.now()
await db.commit()
return api_key
# Update last_used timestamp
api_key.last_used = datetime.utcnow()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
return api_key
def check_permission(api_key: "APIKey", permission: str) -> None:
"""Check if API key has the required permission.
async def get_optional_api_key(
x_api_key: str | None = Header(None, alias="X-API-Key"),
db: AsyncSession = Depends(get_db),
) -> APIKey | None:
"""Get API key if provided, return None otherwise."""
if not x_api_key:
return None
try:
return await get_api_key(x_api_key, db)
except HTTPException:
return None
def check_permission(api_key: APIKey, permission: str) -> None:
"""Check if API key has a specific permission.
Args:
api_key: The API key object
api_key: The API key record
permission: One of 'queue', 'control_printer', 'read_status'
Raises:
HTTPException: If permission is not granted
Raises HTTPException if permission is denied.
"""
from fastapi import HTTPException, status
permission_map = {
"queue": "can_queue",
"control_printer": "can_control_printer",
"read_status": "can_read_status",
"queue": api_key.can_queue,
"control_printer": api_key.can_control_printer,
"read_status": api_key.can_read_status,
}
if permission not in permission_map:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Unknown permission: {permission}",
)
raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}")
attr_name = permission_map[permission]
if not getattr(api_key, attr_name, False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"API key does not have '{permission}' permission",
)
if not permission_map[permission]:
raise HTTPException(status_code=403, detail=f"API key does not have '{permission}' permission")
def check_printer_access(api_key: "APIKey", printer_id: int) -> None:
"""Check if API key has access to the specified printer.
def check_printer_access(api_key: APIKey, printer_id: int) -> None:
"""Check if API key has access to a specific printer.
Args:
api_key: The API key object
printer_id: The printer ID to check access for
api_key: The API key record
printer_id: The printer ID to check
Raises:
HTTPException: If access is denied
Raises HTTPException if access is denied.
"""
from fastapi import HTTPException, status
# If printer_ids is None or empty, access to all printers
if api_key.printer_ids is None or len(api_key.printer_ids) == 0:
return
# Check if printer_id is in allowed list
if printer_id not in api_key.printer_ids:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"API key does not have access to printer {printer_id}",
)
# Convenience dependencies - these are functions that return Depends objects
def RequireAdmin():
"""Dependency that requires admin role."""
return Depends(require_role("admin"))
def RequireAdminIfAuthEnabled():
"""Dependency that requires admin role if auth is enabled."""
return Depends(require_admin_if_auth_enabled())
if api_key.printer_ids is not None and printer_id not in api_key.printer_ids:
raise HTTPException(status_code=403, detail=f"API key does not have access to printer {printer_id}")
-20
View File
@@ -49,7 +49,6 @@ async def init_db():
project_bom,
settings,
smart_plug,
user,
)
async with engine.begin() as conn:
@@ -642,25 +641,6 @@ async def run_migrations(conn):
except Exception:
pass
# Migration: Create users table for authentication
try:
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
""")
)
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_username ON users(username)"))
except Exception:
pass
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
-4
View File
@@ -54,7 +54,6 @@ from backend.app.api.routes import (
ams_history,
api_keys,
archives,
auth,
camera,
cloud,
discovery,
@@ -76,7 +75,6 @@ from backend.app.api.routes import (
support,
system,
updates,
users,
webhook,
websocket,
)
@@ -1963,8 +1961,6 @@ app = FastAPI(
)
# API routes
app.include_router(auth.router, prefix=app_settings.api_prefix)
app.include_router(users.router, prefix=app_settings.api_prefix)
app.include_router(printers.router, prefix=app_settings.api_prefix)
app.include_router(archives.router, prefix=app_settings.api_prefix)
app.include_router(filaments.router, prefix=app_settings.api_prefix)
-2
View File
@@ -12,7 +12,6 @@ from backend.app.models.printer import Printer
from backend.app.models.project import Project
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.user import User
__all__ = [
"Printer",
@@ -32,5 +31,4 @@ __all__ = [
"PendingUpload",
"LibraryFolder",
"LibraryFile",
"User",
]
-20
View File
@@ -1,20 +0,0 @@
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from backend.app.core.database import Base
class User(Base):
"""User model for authentication and authorization."""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(20), default="user") # "admin" or "user"
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
-47
View File
@@ -1,47 +0,0 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: "UserResponse"
class UserCreate(BaseModel):
username: str
password: str
role: str = "user"
class UserUpdate(BaseModel):
username: str | None = None
password: str | None = None
role: str | None = None
is_active: bool | None = None
class UserResponse(BaseModel):
id: int
username: str
role: str
is_active: bool
created_at: str
class Config:
from_attributes = True
class SetupRequest(BaseModel):
auth_enabled: bool
admin_username: str | None = None
admin_password: str | None = None
class SetupResponse(BaseModel):
auth_enabled: bool
admin_created: bool | None = None
-1
View File
@@ -59,7 +59,6 @@ async def test_engine():
project,
settings,
smart_plug,
user,
)
async with engine.begin() as conn:
+14 -39
View File
@@ -11,7 +11,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/dom": "^1.7.4",
"@tanstack/react-query": "^5.90.11",
"@tiptap/extension-color": "^3.11.1",
"@tiptap/extension-image": "^3.11.1",
@@ -143,7 +142,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -494,7 +492,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
},
@@ -517,7 +514,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -545,7 +541,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -1186,6 +1181,7 @@
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
@@ -1194,8 +1190,7 @@
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"peer": true,
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
@@ -1205,7 +1200,8 @@
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
@@ -2312,7 +2308,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.1.tgz",
"integrity": "sha512-q7uzYrCq40JOIi6lceWe2HuA8tSr97iPwP/xtJd0bZjyL1rWhUyqxMb7y+aq4RcELrx/aNRa2JIvLtRRdy02Dg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2561,7 +2556,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.1.tgz",
"integrity": "sha512-XJRN9pOPMi3SsaKv4qM8WBEi3YDrjXYtYlAlZutQe1JpdKykSjLwwYq7k3V8UHqR3YKxyOV8HTYOYoOaZ9TMTQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2667,7 +2661,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.11.1.tgz",
"integrity": "sha512-KLLrABvf609/Z4dPChRowvpqeefYiq5csEj4Ogfp4EFd3KqDvPZIoFepau1+BW4gOAlm8UK+ig+fOLgnUzH7ww==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2694,7 +2687,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.1.tgz",
"integrity": "sha512-/xXJdV+EVvSQv2slvAUChb5iGVv5K0EqBqxPGAAuBHdIc4Y7Id1aaKKSiyDmqon+kjSnnQIIda9oUt+o/Z66uA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2709,7 +2701,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.1.tgz",
"integrity": "sha512-8RIUhlEoCFGsbdNb+EUdQctG1Wnd7rl4wlMLS6giO7UcZT5dVfg625eMZVrl0/kA7JBJdKLIuqNmzzQ0MxsJEw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -2808,7 +2799,8 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -2960,7 +2952,6 @@
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -2970,7 +2961,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2980,7 +2970,6 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3070,7 +3059,6 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
@@ -3440,7 +3428,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3625,7 +3612,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -4188,7 +4174,8 @@
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -4380,7 +4367,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5068,7 +5054,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -5841,6 +5826,7 @@
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -6329,7 +6315,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6357,7 +6342,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -6389,6 +6373,7 @@
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -6403,6 +6388,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"peer": true,
"engines": {
"node": ">=10"
},
@@ -6414,7 +6400,8 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
@@ -6534,7 +6521,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -6564,7 +6550,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -6613,7 +6598,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
"integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -6644,7 +6628,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6654,7 +6637,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -6701,7 +6683,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -6830,8 +6811,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -7428,7 +7408,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7567,7 +7546,6 @@
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -8129,7 +8107,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
"dev": true,
"peer": true,
"dependencies": {
"@vitest/expect": "2.1.9",
"@vitest/mocker": "2.1.9",
@@ -8627,7 +8604,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -9026,7 +9002,6 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
-1
View File
@@ -17,7 +17,6 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@floating-ui/dom": "^1.7.4",
"@tanstack/react-query": "^5.90.11",
"@tiptap/extension-color": "^3.11.1",
"@tiptap/extension-image": "^3.11.1",
+22 -88
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Layout } from './components/Layout';
import { PrintersPage } from './pages/PrintersPage';
@@ -14,13 +14,9 @@ import { FileManagerPage } from './pages/FileManagerPage';
import { CameraPage } from './pages/CameraPage';
import { ExternalLinkPage } from './pages/ExternalLinkPage';
import { SystemInfoPage } from './pages/SystemInfoPage';
import { LoginPage } from './pages/LoginPage';
import { SetupPage } from './pages/SetupPage';
import { UsersPage } from './pages/UsersPage';
import { useWebSocket } from './hooks/useWebSocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
import { AuthProvider, useAuth } from './contexts/AuthContext';
const queryClient = new QueryClient({
defaultOptions: {
@@ -36,95 +32,33 @@ function WebSocketProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { authEnabled, loading, user } = useAuth();
if (loading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
}
if (authEnabled && !user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function AdminRoute({ children }: { children: React.ReactNode }) {
const { authEnabled, loading, user } = useAuth();
if (loading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
}
// 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 <Navigate to="/login" replace />;
}
// If user is not admin, redirect to home
if (user.role !== 'admin') {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
function SetupRoute({ children }: { children: React.ReactNode }) {
const { authEnabled, loading } = useAuth();
if (loading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
}
if (authEnabled) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function App() {
return (
<ThemeProvider>
<ToastProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<Routes>
{/* Setup page - only accessible if auth not enabled */}
<Route path="/setup" element={<SetupRoute><SetupPage /></SetupRoute>} />
<BrowserRouter>
<Routes>
{/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
<Route path="/camera/:printerId" element={<CameraPage />} />
{/* Login page */}
<Route path="/login" element={<LoginPage />} />
{/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
<Route path="/camera/:printerId" element={<CameraPage />} />
{/* Main app with WebSocket for real-time updates */}
<Route element={<ProtectedRoute><WebSocketProvider><Layout /></WebSocketProvider></ProtectedRoute>}>
<Route index element={<PrintersPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="queue" element={<QueuePage />} />
<Route path="stats" element={<StatsPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id" element={<ProjectDetailPage />} />
<Route path="files" element={<FileManagerPage />} />
<Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
<Route path="users" element={<UsersPage />} />
<Route path="system" element={<SystemInfoPage />} />
<Route path="external/:id" element={<ExternalLinkPage />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
{/* Main app with WebSocket for real-time updates */}
<Route element={<WebSocketProvider><Layout /></WebSocketProvider>}>
<Route index element={<PrintersPage />} />
<Route path="archives" element={<ArchivesPage />} />
<Route path="queue" element={<QueuePage />} />
<Route path="stats" element={<StatsPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id" element={<ProjectDetailPage />} />
<Route path="files" element={<FileManagerPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="system" element={<SystemInfoPage />} />
<Route path="external/:id" element={<ExternalLinkPage />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
</ToastProvider>
</ThemeProvider>
@@ -49,9 +49,6 @@ describe('Layout', () => {
}),
http.get('/api/v1/updates/check', () => {
return HttpResponse.json({ update_available: false });
}),
http.get('/api/v1/auth/status', () => {
return HttpResponse.json({ auth_enabled: false, requires_setup: false });
})
);
});
@@ -60,9 +60,6 @@ describe('SettingsPage', () => {
}),
http.get('/api/v1/virtual-printer/status', () => {
return HttpResponse.json({ running: false });
}),
http.get('/api/v1/auth/status', () => {
return HttpResponse.json({ auth_enabled: false, requires_setup: false });
})
);
});
+1 -4
View File
@@ -9,7 +9,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from '../contexts/ThemeContext';
import { ToastProvider } from '../contexts/ToastContext';
import { AuthProvider } from '../contexts/AuthContext';
// Create a new QueryClient for each test
function createTestQueryClient() {
@@ -37,9 +36,7 @@ function AllProviders({ children }: AllProvidersProps) {
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<ToastProvider>{children}</ToastProvider>
</AuthProvider>
<ToastProvider>{children}</ToastProvider>
</ThemeProvider>
</BrowserRouter>
</QueryClientProvider>
+4 -121
View File
@@ -1,47 +1,19 @@
const API_BASE = '/api/v1';
// Auth token storage
let authToken: string | null = localStorage.getItem('auth_token');
export function setAuthToken(token: string | null) {
authToken = token;
if (token) {
localStorage.setItem('auth_token', token);
} else {
localStorage.removeItem('auth_token');
}
}
export function getAuthToken(): string | null {
return authToken;
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers as Record<string, string>,
};
// Add auth token if available
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
cache: 'no-store', // Prevent browser caching of API responses
headers,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
// Handle 401 Unauthorized - clear token and redirect to login
if (response.status === 401) {
setAuthToken(null);
// Don't throw here - let the auth context handle redirect
}
const error = await response.json().catch(() => ({}));
const detail = error.detail;
const message = typeof detail === 'string'
@@ -1403,97 +1375,8 @@ export interface ExternalLinkUpdate {
icon?: string;
}
// Auth types
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
user: UserResponse;
}
export interface UserResponse {
id: number;
username: string;
role: string;
is_active: boolean;
created_at: string;
}
export interface UserCreate {
username: string;
password: string;
role: string;
}
export interface UserUpdate {
username?: string;
password?: string;
role?: string;
is_active?: boolean;
}
export interface SetupRequest {
auth_enabled: boolean;
admin_username?: string;
admin_password?: string;
}
export interface SetupResponse {
auth_enabled: boolean;
admin_created?: boolean;
}
export interface AuthStatus {
auth_enabled: boolean;
requires_setup: boolean;
}
// API functions
export const api = {
// Authentication
getAuthStatus: () => request<AuthStatus>('/auth/status'),
setupAuth: (data: SetupRequest) =>
request<SetupResponse>('/auth/setup', {
method: 'POST',
body: JSON.stringify(data),
}),
login: (data: LoginRequest) =>
request<LoginResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(data),
}),
logout: () =>
request<{ message: string }>('/auth/logout', {
method: 'POST',
}),
getCurrentUser: () => request<UserResponse>('/auth/me'),
disableAuth: () =>
request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
method: 'POST',
}),
// Users (admin only)
getUsers: () => request<UserResponse[]>('/users/'),
getUser: (id: number) => request<UserResponse>(`/users/${id}`),
createUser: (data: UserCreate) =>
request<UserResponse>('/users/', {
method: 'POST',
body: JSON.stringify(data),
}),
updateUser: (id: number, data: UserUpdate) =>
request<UserResponse>(`/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteUser: (id: number) =>
request<void>(`/users/${id}`, {
method: 'DELETE',
}),
// Printers
getPrinters: () => request<Printer[]>('/printers/'),
getPrinter: (id: number) => request<Printer>(`/printers/${id}`),
+1 -33
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, type LucideIcon } from 'lucide-react';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -9,7 +9,6 @@ import { useQuery } from '@tanstack/react-query';
import { api, supportApi, pendingUploadsApi } from '../api/client';
import { getIconByName } from './IconPicker';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAuth } from '../contexts/AuthContext';
interface NavItem {
id: string;
@@ -69,7 +68,6 @@ export function Layout() {
const { mode, toggleMode } = useTheme();
const { t } = useTranslation();
const isMobile = useIsMobile();
const { user, authEnabled, logout } = useAuth();
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
const stored = localStorage.getItem('sidebarExpanded');
return stored !== 'false';
@@ -170,20 +168,12 @@ export function Layout() {
const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
// Compute the ordered sidebar: include stored order + any new items
// Filter out 'settings' for users with 'user' role
const orderedSidebarIds = (() => {
const result: string[] = [];
const seen = new Set<string>();
// Determine if settings should be hidden (user role and auth enabled)
const hideSettings = authEnabled && user?.role === 'user';
// Add items in stored order
for (const id of sidebarOrder) {
// Skip settings if user is not admin
if (hideSettings && id === 'settings') {
continue;
}
if (navItemsMap.has(id) || extLinksMap.has(id)) {
result.push(id);
seen.add(id);
@@ -192,10 +182,6 @@ export function Layout() {
// Add any new internal nav items not in stored order
for (const item of defaultNavItems) {
// Skip settings if user is not admin
if (hideSettings && item.id === 'settings') {
continue;
}
if (!seen.has(item.id)) {
result.push(item.id);
seen.add(item.id);
@@ -579,15 +565,6 @@ export function Layout() {
>
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
{authEnabled && user && (
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.logout', { defaultValue: 'Logout' })}
>
<LogOut className="w-5 h-5" />
</button>
)}
</div>
{/* Bottom row: version */}
<div className="flex items-center justify-center gap-2">
@@ -665,15 +642,6 @@ export function Layout() {
>
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
{authEnabled && user && (
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.logout', { defaultValue: 'Logout' })}
>
<LogOut className="w-5 h-5" />
</button>
)}
</div>
)}
</div>
-116
View File
@@ -1,116 +0,0 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { api, getAuthToken, setAuthToken } from '../api/client';
import type { UserResponse } from '../api/client';
interface AuthContextType {
user: UserResponse | null;
authEnabled: boolean;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
refreshAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<UserResponse | null>(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 (
<AuthContext.Provider
value={{
user,
authEnabled,
loading,
login,
logout,
refreshUser,
refreshAuth,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
-103
View File
@@ -1,103 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { useTheme } from '../contexts/ThemeContext';
export function LoginPage() {
const navigate = useNavigate();
const { login } = useAuth();
const { showToast } = useToast();
const { mode } = useTheme();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const loginMutation = useMutation({
mutationFn: () => login(username, password),
onSuccess: () => {
showToast('Logged in successfully');
navigate('/');
},
onError: (error: Error) => {
showToast(error.message || 'Login failed', 'error');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
showToast('Please enter username and password', 'error');
return;
}
loginMutation.mutate();
};
return (
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
<div className="text-center">
<div className="flex items-center justify-center mb-6">
<img
src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
alt="Bambuddy"
className="h-16"
/>
</div>
<h2 className="text-3xl font-bold text-white">
Bambuddy Login
</h2>
<p className="mt-2 text-sm text-bambu-gray">
Sign in to your account
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-white mb-2">
Username
</label>
<input
id="username"
type="text"
required
value={username}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-white mb-2">
Password
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
>
{loginMutation.isPending ? 'Logging in...' : 'Sign in'}
</button>
</div>
</form>
</div>
</div>
);
}
+3 -242
View File
@@ -1,9 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock } from 'lucide-react';
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Upload, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, Info, X, Shield, Printer, Cylinder, Wifi, Home, Video } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { formatDateOnly } from '../utils/date';
import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
import { Card, CardContent, CardHeader } from '../components/Card';
@@ -31,10 +29,8 @@ import { Palette } from 'lucide-react';
export function SettingsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const { showToast, showPersistentToast, dismissToast } = useToast();
const { authEnabled, user, refreshAuth } = useAuth();
const {
mode,
darkStyle, darkBackground, darkAccent,
@@ -50,7 +46,7 @@ export function SettingsPage() {
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
const [showLogViewer, setShowLogViewer] = useState(false);
const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
const [activeTab, setActiveTab] = useState<'general' | 'network' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer' | 'users'>('general');
const [activeTab, setActiveTab] = useState<'general' | 'network' | 'plugs' | 'notifications' | 'filament' | 'apikeys' | 'virtual-printer'>('general');
const [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
const [newAPIKeyName, setNewAPIKeyName] = useState('');
const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -70,7 +66,6 @@ export function SettingsPage() {
const [showRestoreModal, setShowRestoreModal] = useState(false);
const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
const [showReleaseNotes, setShowReleaseNotes] = useState(false);
const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false);
// Home Assistant test connection state
const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
@@ -479,7 +474,7 @@ export function SettingsPage() {
</div>
{/* Tab Navigation */}
<div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary overflow-x-auto">
<div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
<button
onClick={() => setActiveTab('general')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
@@ -573,20 +568,6 @@ export function SettingsPage() {
Virtual Printer
<span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
</button>
<button
onClick={() => setActiveTab('users')}
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
activeTab === 'users'
? 'text-bambu-green border-bambu-green'
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
}`}
>
<Users className="w-4 h-4" />
Users
{authEnabled && (
<span className={`w-2 h-2 rounded-full ${authEnabled ? 'bg-green-400' : 'bg-gray-500'}`} />
)}
</button>
</div>
{/* General Tab */}
@@ -2885,226 +2866,6 @@ export function SettingsPage() {
</Card>
</div>
)}
{/* Users Tab */}
{activeTab === 'users' && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
<div>
<div className="mb-6">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-bambu-green" />
User Authentication
</h2>
<p className="text-sm text-bambu-gray mt-1">
Enable authentication to secure your Bambuddy instance and manage user access.
</p>
</div>
<Card>
<CardContent className="py-6">
{!authEnabled ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${authEnabled ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
{authEnabled ? (
<Lock className="w-6 h-6 text-green-400" />
) : (
<Unlock className="w-6 h-6 text-gray-400" />
)}
</div>
<div className="flex-1">
<h3 className="text-white font-medium">Authentication Disabled</h3>
<p className="text-sm text-bambu-gray">
Your Bambuddy instance is currently accessible without authentication.
</p>
</div>
</div>
<div className="pt-4 border-t border-bambu-dark-tertiary">
<p className="text-sm text-bambu-gray mb-4">
Enable authentication to:
</p>
<ul className="space-y-2 text-sm text-bambu-gray mb-4">
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span>Require login to access the system</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span>Manage multiple users with different roles</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span>Control access to printer settings and user management</span>
</li>
</ul>
<Button
onClick={() => navigate('/setup')}
className="w-full"
>
<Lock className="w-4 h-4" />
Activate Authentication
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-green-500/20">
<Lock className="w-6 h-6 text-green-400" />
</div>
<div className="flex-1">
<h3 className="text-white font-medium">Authentication Enabled</h3>
<p className="text-sm text-bambu-gray">
Your Bambuddy instance is secured with authentication.
</p>
</div>
</div>
{user && (
<div className="pt-4 border-t border-bambu-dark-tertiary">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm text-bambu-gray">Current User</p>
<p className="text-white font-medium">{user.username}</p>
<p className="text-xs text-bambu-gray mt-1">
Role: <span className="capitalize">{user.role}</span>
</p>
</div>
<div className={`px-3 py-1 rounded-full text-xs font-medium ${
user.role === 'admin'
? 'bg-purple-500/20 text-purple-300'
: 'bg-blue-500/20 text-blue-300'
}`}>
{user.role === 'admin' ? 'Admin' : 'User'}
</div>
</div>
</div>
)}
<div className="pt-4 border-t border-bambu-dark-tertiary space-y-3">
<Button
onClick={() => navigate('/users')}
className="w-full"
variant="secondary"
>
<Users className="w-4 h-4" />
Manage Users
</Button>
{user?.role === 'admin' && (
<Button
onClick={() => setShowDisableAuthConfirm(true)}
className="w-full"
variant="secondary"
>
<Unlock className="w-4 h-4" />
Disable Authentication
</Button>
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
{authEnabled && (
<div>
<div className="mb-6">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Shield className="w-5 h-5 text-bambu-green" />
Role Permissions
</h2>
<p className="text-sm text-bambu-gray mt-1">
Overview of what each role can do.
</p>
</div>
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center">
<Shield className="w-4 h-4 text-purple-300" />
</div>
<h3 className="text-white font-medium">Admin</h3>
</div>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-bambu-gray">
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span>Manage printer settings</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span>Create, edit, and delete users</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span>Access all system features</span>
</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center">
<Users className="w-4 h-4 text-blue-300" />
</div>
<h3 className="text-white font-medium">User</h3>
</div>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-bambu-gray">
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span>Send print jobs</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span>Manage files and archives</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
<span>Manage filament</span>
</li>
</ul>
</CardContent>
</Card>
</div>
</div>
)}
</div>
)}
{/* Disable Authentication Confirmation Modal */}
{showDisableAuthConfirm && (
<ConfirmModal
title="Disable Authentication"
message="Are you sure you want to disable authentication? This will make your Bambuddy instance accessible without login. All users will remain in the database but authentication will be disabled."
confirmText="Disable Authentication"
variant="danger"
onConfirm={async () => {
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)}
/>
)}
</div>
);
}
-161
View File
@@ -1,161 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { api } from '../api/client';
import { useToast } from '../contexts/ToastContext';
import { useTheme } from '../contexts/ThemeContext';
export function SetupPage() {
const navigate = useNavigate();
const { showToast } = useToast();
const { mode } = useTheme();
const [authEnabled, setAuthEnabled] = useState(false);
const [adminUsername, setAdminUsername] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const setupMutation = useMutation({
mutationFn: () =>
api.setupAuth({
auth_enabled: authEnabled,
admin_username: authEnabled ? adminUsername : undefined,
admin_password: authEnabled ? adminPassword : undefined,
}),
onSuccess: (data) => {
if (data.auth_enabled && data.admin_created) {
showToast('Authentication enabled and admin user created');
navigate('/login');
} else {
showToast('Setup completed');
navigate('/');
}
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (authEnabled) {
if (!adminUsername || !adminPassword) {
showToast('Please enter admin username and password', 'error');
return;
}
if (adminPassword !== confirmPassword) {
showToast('Passwords do not match', 'error');
return;
}
if (adminPassword.length < 6) {
showToast('Password must be at least 6 characters', 'error');
return;
}
}
setupMutation.mutate();
};
return (
<div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
<div className="max-w-md w-full space-y-8 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
<div className="text-center">
<div className="flex items-center justify-center mb-6">
<img
src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
alt="Bambuddy"
className="h-16"
/>
</div>
<h2 className="text-3xl font-bold text-white">
Bambuddy Setup
</h2>
<p className="mt-2 text-sm text-bambu-gray">
Configure authentication for your Bambuddy instance
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div className="flex items-center p-4 bg-bambu-dark-secondary/50 rounded-lg border border-bambu-dark-tertiary">
<input
id="auth-enabled"
type="checkbox"
checked={authEnabled}
onChange={(e) => 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"
/>
<label htmlFor="auth-enabled" className="ml-3 block text-sm font-medium text-white">
Enable Authentication
</label>
</div>
{authEnabled && (
<div className="space-y-4 mt-4">
<div>
<label htmlFor="admin-username" className="block text-sm font-medium text-white mb-2">
Admin Username
</label>
<input
id="admin-username"
type="text"
required
value={adminUsername}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="admin-password" className="block text-sm font-medium text-white mb-2">
Admin Password
</label>
<input
id="admin-password"
type="password"
required
value={adminPassword}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
Confirm Password
</label>
<input
id="confirm-password"
type="password"
required
value={confirmPassword}
onChange={(e) => 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"
/>
</div>
</div>
)}
</div>
<div>
<button
type="submit"
disabled={setupMutation.isPending}
className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
>
{setupMutation.isPending ? 'Setting up...' : 'Complete Setup'}
</button>
</div>
</form>
</div>
</div>
);
}
-398
View File
@@ -1,398 +0,0 @@
import { useState, useEffect } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield } from 'lucide-react';
import { api } from '../api/client';
import type { UserCreate, UserUpdate } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { Button } from '../components/Button';
import { Card, CardContent, CardHeader } from '../components/Card';
import { ConfirmModal } from '../components/ConfirmModal';
export function UsersPage() {
const { user: currentUser } = useAuth();
const { showToast } = useToast();
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState<number | null>(null);
const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
const [formData, setFormData] = useState<UserCreate>({
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 (
<div className="p-6">
<Card>
<CardContent className="py-6">
<div className="flex items-center gap-3 text-red-400">
<Shield className="w-5 h-5" />
<p className="text-white">You do not have permission to access this page.</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<UsersIcon className="w-6 h-6 text-bambu-green" />
User Management
</h1>
<p className="text-sm text-bambu-gray mt-1">
Manage users and their access to your Bambuddy instance
</p>
</div>
<Button
onClick={() => {
setShowCreateModal(true);
setFormData({ username: '', password: '', role: 'user' });
}}
>
<Plus className="w-4 h-4" />
Create User
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : (
<Card>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-bambu-dark-tertiary">
<thead>
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
Username
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-bambu-dark-tertiary">
{users.map((user) => (
<tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
{editingUser === user.id ? (
<input
type="text"
value={formData.username}
onChange={(e) => 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
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
{editingUser === user.id ? (
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
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"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
) : (
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
user.role === 'admin'
? 'bg-purple-500/20 text-purple-300'
: 'bg-blue-500/20 text-blue-300'
}`}>
{user.role}
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
user.is_active
? 'bg-bambu-green/20 text-bambu-green'
: 'bg-red-500/20 text-red-400'
}`}>
{user.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{editingUser === user.id ? (
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => handleUpdate(user.id)}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => {
setEditingUser(null);
setFormData({ username: '', password: '', role: 'user' });
}}
>
Cancel
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => startEdit(user)}
>
<Edit2 className="w-4 h-4" />
Edit
</Button>
{user.id !== currentUser?.id && (
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(user.id)}
>
<Trash2 className="w-4 h-4" />
Delete
</Button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{/* Create User Modal */}
{showCreateModal && (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={() => {
setShowCreateModal(false);
setFormData({ username: '', password: '', role: 'user' });
}}
>
<Card
className="w-full max-w-md"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UsersIcon className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Create User</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowCreateModal(false);
setFormData({ username: '', password: '', role: 'user' });
}}
>
<X className="w-5 h-5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Username
</label>
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter username"
autoComplete="username"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Password
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter password"
autoComplete="new-password"
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Role
</label>
<select
value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => {
setShowCreateModal(false);
setFormData({ username: '', password: '', role: 'user' });
}}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={createMutation.isPending || !formData.username || !formData.password}
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4" />
Create User
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteUserId !== null && (
<ConfirmModal
title="Delete User"
message={`Are you sure you want to delete this user? This action cannot be undone.`}
confirmText="Delete User"
variant="danger"
onConfirm={() => {
deleteMutation.mutate(deleteUserId);
setDeleteUserId(null);
}}
onCancel={() => setDeleteUserId(null)}
/>
)}
</div>
);
}
-1
View File
@@ -16,7 +16,6 @@ export default {
dark: '#1a1a1a',
'dark-secondary': '#2d2d2d',
'dark-tertiary': '#3d3d3d',
card: '#2d2d2d', // Same as dark-secondary for card backgrounds
gray: '#808080',
'gray-light': '#a0a0a0',
'gray-dark': '#4a4a4a',
-4
View File
@@ -37,10 +37,6 @@ qrcode[pil]>=7.4.0
# System monitoring
psutil>=6.0.0
# Authentication
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
# Development
pytest>=8.0.0
pytest-asyncio>=0.23.0
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BK0uiRXP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-COZJGA_d.css">
<script type="module" crossorigin src="/assets/index-DFo1_Rau.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BmODu1qm.css">
</head>
<body>
<div id="root"></div>