Browse Source

Added optional authentication and user management

maziggy 4 months ago
parent
commit
af49d7686e

+ 213 - 0
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"}

+ 4 - 0
backend/app/api/routes/printers.py

@@ -8,6 +8,7 @@ from fastapi.responses import Response
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
+from backend.app.core.auth import RequireAdminIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
@@ -47,6 +48,7 @@ async def list_printers(db: AsyncSession = Depends(get_db)):
 async def create_printer(
 async def create_printer(
     printer_data: PrinterCreate,
     printer_data: PrinterCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _current_user=RequireAdminIfAuthEnabled(),
 ):
 ):
     """Add a new printer."""
     """Add a new printer."""
     # Check if serial number already exists
     # Check if serial number already exists
@@ -81,6 +83,7 @@ async def update_printer(
     printer_id: int,
     printer_id: int,
     printer_data: PrinterUpdate,
     printer_data: PrinterUpdate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _current_user=RequireAdminIfAuthEnabled(),
 ):
 ):
     """Update a printer."""
     """Update a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -109,6 +112,7 @@ async def delete_printer(
     printer_id: int,
     printer_id: int,
     delete_archives: bool = True,
     delete_archives: bool = True,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
+    _current_user=RequireAdminIfAuthEnabled(),
 ):
 ):
     """Delete a printer.
     """Delete a printer.
 
 

+ 205 - 0
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()

+ 306 - 61
backend/app/core/auth.py

@@ -1,105 +1,350 @@
-import hashlib
 import secrets
 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 import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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
 
 
-def generate_api_key() -> tuple[str, str, str]:
-    """Generate a new API key.
+# 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")
 
 
-    Returns:
-        Tuple of (full_key, key_hash, key_prefix)
-    """
-    # 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
+# 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 hash_api_key(key: str) -> str:
-    """Hash an API key for comparison."""
-    return hashlib.sha256(key.encode()).hexdigest()
 
 
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+    """Verify a password against a hash.
 
 
-async def get_api_key(
-    x_api_key: str = Header(..., alias="X-API-Key"),
-    db: AsyncSession = Depends(get_db),
-) -> APIKey:
-    """Verify API key and return the key record.
+    Uses pbkdf2_sha256 which handles long passwords automatically.
+    """
+    return pwd_context.verify(plain_password, hashed_password)
 
 
-    Raises HTTPException if key is invalid, disabled, or expired.
+
+def get_password_hash(password: str) -> str:
+    """Hash a password.
+
+    Uses pbkdf2_sha256 which is secure and has no password length limit.
     """
     """
-    key_hash = hash_api_key(x_api_key)
+    return pwd_context.hash(password)
 
 
-    result = await db.execute(select(APIKey).where(APIKey.key_hash == key_hash))
-    api_key = result.scalar_one_or_none()
 
 
-    if not api_key:
-        raise HTTPException(status_code=401, detail="Invalid API key")
+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
 
 
-    if not api_key.enabled:
-        raise HTTPException(status_code=403, detail="API key is disabled")
 
 
-    if api_key.expires_at and api_key.expires_at < datetime.utcnow():
-        raise HTTPException(status_code=403, detail="API key has expired")
+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()
 
 
-    # Update last_used timestamp
-    api_key.last_used = datetime.utcnow()
 
 
-    return api_key
+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_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:
+
+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
         return None
 
 
     try:
     try:
-        return await get_api_key(x_api_key, db)
-    except HTTPException:
+        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
         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 check_permission(api_key: APIKey, permission: str) -> None:
-    """Check if API key has a specific permission.
+
+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: (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 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
+
+
+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,
+    db: AsyncSession = Depends(get_db),
+) -> "APIKey":
+    """Get and validate API key from request headers.
+
+    Checks both 'Authorization: Bearer <key>' and 'X-API-Key: <key>' headers.
+    """
+    from fastapi import HTTPException, status
+
+    from backend.app.models.api_key import APIKey
+
+    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_value:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="API key required. Provide 'X-API-Key' header or 'Authorization: Bearer <key>'",
+        )
+
+    # Get all API keys and check them
+    result = await db.execute(select(APIKey).where(APIKey.enabled.is_(True)))
+    api_keys = result.scalars().all()
+
+    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
+
+    raise HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Invalid API key",
+    )
+
+
+def check_permission(api_key: "APIKey", permission: str) -> None:
+    """Check if API key has the required permission.
 
 
     Args:
     Args:
-        api_key: The API key record
+        api_key: The API key object
         permission: One of 'queue', 'control_printer', 'read_status'
         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 = {
     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:
     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:
     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())

+ 20 - 0
backend/app/core/database.py

@@ -49,6 +49,7 @@ async def init_db():
         project_bom,
         project_bom,
         settings,
         settings,
         smart_plug,
         smart_plug,
+        user,
     )
     )
 
 
     async with engine.begin() as conn:
     async with engine.begin() as conn:
@@ -641,6 +642,25 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         pass
         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():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""

+ 4 - 0
backend/app/main.py

@@ -54,6 +54,7 @@ from backend.app.api.routes import (
     ams_history,
     ams_history,
     api_keys,
     api_keys,
     archives,
     archives,
+    auth,
     camera,
     camera,
     cloud,
     cloud,
     discovery,
     discovery,
@@ -75,6 +76,7 @@ from backend.app.api.routes import (
     support,
     support,
     system,
     system,
     updates,
     updates,
+    users,
     webhook,
     webhook,
     websocket,
     websocket,
 )
 )
@@ -1961,6 +1963,8 @@ app = FastAPI(
 )
 )
 
 
 # API routes
 # 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(printers.router, prefix=app_settings.api_prefix)
 app.include_router(archives.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)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)

+ 2 - 0
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.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
+from backend.app.models.user import User
 
 
 __all__ = [
 __all__ = [
     "Printer",
     "Printer",
@@ -31,4 +32,5 @@ __all__ = [
     "PendingUpload",
     "PendingUpload",
     "LibraryFolder",
     "LibraryFolder",
     "LibraryFile",
     "LibraryFile",
+    "User",
 ]
 ]

+ 20 - 0
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())

+ 47 - 0
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

+ 1 - 0
backend/tests/conftest.py

@@ -59,6 +59,7 @@ async def test_engine():
         project,
         project,
         settings,
         settings,
         smart_plug,
         smart_plug,
+        user,
     )
     )
 
 
     async with engine.begin() as conn:
     async with engine.begin() as conn:

+ 39 - 14
frontend/package-lock.json

@@ -11,6 +11,7 @@
         "@dnd-kit/core": "^6.3.1",
         "@dnd-kit/core": "^6.3.1",
         "@dnd-kit/sortable": "^10.0.0",
         "@dnd-kit/sortable": "^10.0.0",
         "@dnd-kit/utilities": "^3.2.2",
         "@dnd-kit/utilities": "^3.2.2",
+        "@floating-ui/dom": "^1.7.4",
         "@tanstack/react-query": "^5.90.11",
         "@tanstack/react-query": "^5.90.11",
         "@tiptap/extension-color": "^3.11.1",
         "@tiptap/extension-color": "^3.11.1",
         "@tiptap/extension-image": "^3.11.1",
         "@tiptap/extension-image": "^3.11.1",
@@ -142,6 +143,7 @@
       "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
       "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@babel/code-frame": "^7.27.1",
         "@babel/code-frame": "^7.27.1",
         "@babel/generator": "^7.28.5",
         "@babel/generator": "^7.28.5",
@@ -492,6 +494,7 @@
           "url": "https://opencollective.com/csstools"
           "url": "https://opencollective.com/csstools"
         }
         }
       ],
       ],
+      "peer": true,
       "engines": {
       "engines": {
         "node": ">=18"
         "node": ">=18"
       },
       },
@@ -514,6 +517,7 @@
           "url": "https://opencollective.com/csstools"
           "url": "https://opencollective.com/csstools"
         }
         }
       ],
       ],
+      "peer": true,
       "engines": {
       "engines": {
         "node": ">=18"
         "node": ">=18"
       }
       }
@@ -541,6 +545,7 @@
       "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
       "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
       "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
       "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@dnd-kit/accessibility": "^3.1.1",
         "@dnd-kit/accessibility": "^3.1.1",
         "@dnd-kit/utilities": "^3.2.2",
         "@dnd-kit/utilities": "^3.2.2",
@@ -1181,7 +1186,6 @@
       "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
       "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
       "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
       "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
       "license": "MIT",
       "license": "MIT",
-      "optional": true,
       "dependencies": {
       "dependencies": {
         "@floating-ui/utils": "^0.2.10"
         "@floating-ui/utils": "^0.2.10"
       }
       }
@@ -1190,7 +1194,8 @@
       "version": "1.7.4",
       "version": "1.7.4",
       "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
       "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
       "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
       "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
-      "optional": true,
+      "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@floating-ui/core": "^1.7.3",
         "@floating-ui/core": "^1.7.3",
         "@floating-ui/utils": "^0.2.10"
         "@floating-ui/utils": "^0.2.10"
@@ -1200,8 +1205,7 @@
       "version": "0.2.10",
       "version": "0.2.10",
       "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
       "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
       "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
       "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
-      "license": "MIT",
-      "optional": true
+      "license": "MIT"
     },
     },
     "node_modules/@humanfs/core": {
     "node_modules/@humanfs/core": {
       "version": "0.19.1",
       "version": "0.19.1",
@@ -2308,6 +2312,7 @@
       "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.1.tgz",
       "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.11.1.tgz",
       "integrity": "sha512-q7uzYrCq40JOIi6lceWe2HuA8tSr97iPwP/xtJd0bZjyL1rWhUyqxMb7y+aq4RcELrx/aNRa2JIvLtRRdy02Dg==",
       "integrity": "sha512-q7uzYrCq40JOIi6lceWe2HuA8tSr97iPwP/xtJd0bZjyL1rWhUyqxMb7y+aq4RcELrx/aNRa2JIvLtRRdy02Dg==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "funding": {
       "funding": {
         "type": "github",
         "type": "github",
         "url": "https://github.com/sponsors/ueberdosis"
         "url": "https://github.com/sponsors/ueberdosis"
@@ -2556,6 +2561,7 @@
       "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.1.tgz",
       "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.11.1.tgz",
       "integrity": "sha512-XJRN9pOPMi3SsaKv4qM8WBEi3YDrjXYtYlAlZutQe1JpdKykSjLwwYq7k3V8UHqR3YKxyOV8HTYOYoOaZ9TMTQ==",
       "integrity": "sha512-XJRN9pOPMi3SsaKv4qM8WBEi3YDrjXYtYlAlZutQe1JpdKykSjLwwYq7k3V8UHqR3YKxyOV8HTYOYoOaZ9TMTQ==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "funding": {
       "funding": {
         "type": "github",
         "type": "github",
         "url": "https://github.com/sponsors/ueberdosis"
         "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",
       "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.11.1.tgz",
       "integrity": "sha512-KLLrABvf609/Z4dPChRowvpqeefYiq5csEj4Ogfp4EFd3KqDvPZIoFepau1+BW4gOAlm8UK+ig+fOLgnUzH7ww==",
       "integrity": "sha512-KLLrABvf609/Z4dPChRowvpqeefYiq5csEj4Ogfp4EFd3KqDvPZIoFepau1+BW4gOAlm8UK+ig+fOLgnUzH7ww==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "funding": {
       "funding": {
         "type": "github",
         "type": "github",
         "url": "https://github.com/sponsors/ueberdosis"
         "url": "https://github.com/sponsors/ueberdosis"
@@ -2687,6 +2694,7 @@
       "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.1.tgz",
       "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.11.1.tgz",
       "integrity": "sha512-/xXJdV+EVvSQv2slvAUChb5iGVv5K0EqBqxPGAAuBHdIc4Y7Id1aaKKSiyDmqon+kjSnnQIIda9oUt+o/Z66uA==",
       "integrity": "sha512-/xXJdV+EVvSQv2slvAUChb5iGVv5K0EqBqxPGAAuBHdIc4Y7Id1aaKKSiyDmqon+kjSnnQIIda9oUt+o/Z66uA==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "funding": {
       "funding": {
         "type": "github",
         "type": "github",
         "url": "https://github.com/sponsors/ueberdosis"
         "url": "https://github.com/sponsors/ueberdosis"
@@ -2701,6 +2709,7 @@
       "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.1.tgz",
       "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.11.1.tgz",
       "integrity": "sha512-8RIUhlEoCFGsbdNb+EUdQctG1Wnd7rl4wlMLS6giO7UcZT5dVfg625eMZVrl0/kA7JBJdKLIuqNmzzQ0MxsJEw==",
       "integrity": "sha512-8RIUhlEoCFGsbdNb+EUdQctG1Wnd7rl4wlMLS6giO7UcZT5dVfg625eMZVrl0/kA7JBJdKLIuqNmzzQ0MxsJEw==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "prosemirror-changeset": "^2.3.0",
         "prosemirror-changeset": "^2.3.0",
         "prosemirror-collab": "^1.3.1",
         "prosemirror-collab": "^1.3.1",
@@ -2799,8 +2808,7 @@
       "version": "5.0.4",
       "version": "5.0.4",
       "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
       "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
       "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
       "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     },
     "node_modules/@types/babel__core": {
     "node_modules/@types/babel__core": {
       "version": "7.20.5",
       "version": "7.20.5",
@@ -2952,6 +2960,7 @@
       "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
       "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "undici-types": "~7.16.0"
         "undici-types": "~7.16.0"
       }
       }
@@ -2961,6 +2970,7 @@
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
       "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
       "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
       "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "csstype": "^3.2.2"
         "csstype": "^3.2.2"
       }
       }
@@ -2970,6 +2980,7 @@
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
       "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
       "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "peerDependencies": {
       "peerDependencies": {
         "@types/react": "^19.2.0"
         "@types/react": "^19.2.0"
       }
       }
@@ -3059,6 +3070,7 @@
       "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
       "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@typescript-eslint/scope-manager": "8.48.0",
         "@typescript-eslint/scope-manager": "8.48.0",
         "@typescript-eslint/types": "8.48.0",
         "@typescript-eslint/types": "8.48.0",
@@ -3428,6 +3440,7 @@
       "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
       "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "bin": {
       "bin": {
         "acorn": "bin/acorn"
         "acorn": "bin/acorn"
       },
       },
@@ -3612,6 +3625,7 @@
         }
         }
       ],
       ],
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "baseline-browser-mapping": "^2.8.25",
         "baseline-browser-mapping": "^2.8.25",
         "caniuse-lite": "^1.0.30001754",
         "caniuse-lite": "^1.0.30001754",
@@ -4174,8 +4188,7 @@
       "version": "0.5.16",
       "version": "0.5.16",
       "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
       "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
       "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
       "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     },
     "node_modules/dunder-proto": {
     "node_modules/dunder-proto": {
       "version": "1.0.1",
       "version": "1.0.1",
@@ -4367,6 +4380,7 @@
       "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
       "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.1",
         "@eslint-community/regexpp": "^4.12.1",
@@ -5054,6 +5068,7 @@
         }
         }
       ],
       ],
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@babel/runtime": "^7.28.4"
         "@babel/runtime": "^7.28.4"
       },
       },
@@ -5826,7 +5841,6 @@
       "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
       "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
       "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
       "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
       "dev": true,
       "dev": true,
-      "peer": true,
       "bin": {
       "bin": {
         "lz-string": "bin/bin.js"
         "lz-string": "bin/bin.js"
       }
       }
@@ -6315,6 +6329,7 @@
       "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
       "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "engines": {
       "engines": {
         "node": ">=12"
         "node": ">=12"
       },
       },
@@ -6342,6 +6357,7 @@
         }
         }
       ],
       ],
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "nanoid": "^3.3.11",
         "nanoid": "^3.3.11",
         "picocolors": "^1.1.1",
         "picocolors": "^1.1.1",
@@ -6373,7 +6389,6 @@
       "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
       "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
       "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
       "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
       "dev": true,
       "dev": true,
-      "peer": true,
       "dependencies": {
       "dependencies": {
         "ansi-regex": "^5.0.1",
         "ansi-regex": "^5.0.1",
         "ansi-styles": "^5.0.0",
         "ansi-styles": "^5.0.0",
@@ -6388,7 +6403,6 @@
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
       "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
       "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
       "dev": true,
       "dev": true,
-      "peer": true,
       "engines": {
       "engines": {
         "node": ">=10"
         "node": ">=10"
       },
       },
@@ -6400,8 +6414,7 @@
       "version": "17.0.2",
       "version": "17.0.2",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
       "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
       "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
-      "dev": true,
-      "peer": true
+      "dev": true
     },
     },
     "node_modules/process-nextick-args": {
     "node_modules/process-nextick-args": {
       "version": "2.0.1",
       "version": "2.0.1",
@@ -6521,6 +6534,7 @@
       "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
       "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
       "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
       "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "orderedmap": "^2.0.0"
         "orderedmap": "^2.0.0"
       }
       }
@@ -6550,6 +6564,7 @@
       "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
       "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
       "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
       "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "prosemirror-model": "^1.0.0",
         "prosemirror-model": "^1.0.0",
         "prosemirror-transform": "^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",
       "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
       "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
       "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "prosemirror-model": "^1.20.0",
         "prosemirror-model": "^1.20.0",
         "prosemirror-state": "^1.0.0",
         "prosemirror-state": "^1.0.0",
@@ -6628,6 +6644,7 @@
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
       "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
       "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
       "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "engines": {
       "engines": {
         "node": ">=0.10.0"
         "node": ">=0.10.0"
       }
       }
@@ -6637,6 +6654,7 @@
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
       "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
       "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
       "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "scheduler": "^0.27.0"
         "scheduler": "^0.27.0"
       },
       },
@@ -6683,6 +6701,7 @@
       "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
       "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
       "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
       "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@types/use-sync-external-store": "^0.0.6",
         "@types/use-sync-external-store": "^0.0.6",
         "use-sync-external-store": "^1.4.0"
         "use-sync-external-store": "^1.4.0"
@@ -6811,7 +6830,8 @@
       "version": "5.0.1",
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
       "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
       "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
       "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
-      "license": "MIT"
+      "license": "MIT",
+      "peer": true
     },
     },
     "node_modules/redux-thunk": {
     "node_modules/redux-thunk": {
       "version": "3.1.0",
       "version": "3.1.0",
@@ -7408,6 +7428,7 @@
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
       "devOptional": true,
       "devOptional": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
+      "peer": true,
       "bin": {
       "bin": {
         "tsc": "bin/tsc",
         "tsc": "bin/tsc",
         "tsserver": "bin/tsserver"
         "tsserver": "bin/tsserver"
@@ -7546,6 +7567,7 @@
       "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
       "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "esbuild": "^0.25.0",
         "esbuild": "^0.25.0",
         "fdir": "^6.5.0",
         "fdir": "^6.5.0",
@@ -8107,6 +8129,7 @@
       "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
       "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
       "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
       "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
       "dev": true,
       "dev": true,
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "@vitest/expect": "2.1.9",
         "@vitest/expect": "2.1.9",
         "@vitest/mocker": "2.1.9",
         "@vitest/mocker": "2.1.9",
@@ -8604,6 +8627,7 @@
       "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
       "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
       "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
       "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
       "dev": true,
       "dev": true,
+      "peer": true,
       "dependencies": {
       "dependencies": {
         "esbuild": "^0.21.3",
         "esbuild": "^0.21.3",
         "postcss": "^8.4.43",
         "postcss": "^8.4.43",
@@ -9002,6 +9026,7 @@
       "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
       "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
+      "peer": true,
       "funding": {
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
         "url": "https://github.com/sponsors/colinhacks"
       }
       }

+ 1 - 0
frontend/package.json

@@ -17,6 +17,7 @@
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/core": "^6.3.1",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/sortable": "^10.0.0",
     "@dnd-kit/utilities": "^3.2.2",
     "@dnd-kit/utilities": "^3.2.2",
+    "@floating-ui/dom": "^1.7.4",
     "@tanstack/react-query": "^5.90.11",
     "@tanstack/react-query": "^5.90.11",
     "@tiptap/extension-color": "^3.11.1",
     "@tiptap/extension-color": "^3.11.1",
     "@tiptap/extension-image": "^3.11.1",
     "@tiptap/extension-image": "^3.11.1",

+ 89 - 23
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { Layout } from './components/Layout';
 import { Layout } from './components/Layout';
 import { PrintersPage } from './pages/PrintersPage';
 import { PrintersPage } from './pages/PrintersPage';
@@ -14,9 +14,13 @@ import { FileManagerPage } from './pages/FileManagerPage';
 import { CameraPage } from './pages/CameraPage';
 import { CameraPage } from './pages/CameraPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 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 { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { ToastProvider } from './contexts/ToastContext';
+import { AuthProvider, useAuth } from './contexts/AuthContext';
 
 
 const queryClient = new QueryClient({
 const queryClient = new QueryClient({
   defaultOptions: {
   defaultOptions: {
@@ -32,33 +36,95 @@ function WebSocketProvider({ children }: { children: React.ReactNode }) {
   return <>{children}</>;
   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() {
 function App() {
   return (
   return (
     <ThemeProvider>
     <ThemeProvider>
       <ToastProvider>
       <ToastProvider>
         <QueryClientProvider client={queryClient}>
         <QueryClientProvider client={queryClient}>
-          <BrowserRouter>
-            <Routes>
-              {/* 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={<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>
+          <AuthProvider>
+            <BrowserRouter>
+              <Routes>
+                {/* Setup page - only accessible if auth not enabled */}
+                <Route path="/setup" element={<SetupRoute><SetupPage /></SetupRoute>} />
+
+                {/* 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>
         </QueryClientProvider>
         </QueryClientProvider>
       </ToastProvider>
       </ToastProvider>
     </ThemeProvider>
     </ThemeProvider>

+ 3 - 0
frontend/src/__tests__/components/Layout.test.tsx

@@ -49,6 +49,9 @@ describe('Layout', () => {
       }),
       }),
       http.get('/api/v1/updates/check', () => {
       http.get('/api/v1/updates/check', () => {
         return HttpResponse.json({ update_available: false });
         return HttpResponse.json({ update_available: false });
+      }),
+      http.get('/api/v1/auth/status', () => {
+        return HttpResponse.json({ auth_enabled: false, requires_setup: false });
       })
       })
     );
     );
   });
   });

+ 3 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -60,6 +60,9 @@ describe('SettingsPage', () => {
       }),
       }),
       http.get('/api/v1/virtual-printer/status', () => {
       http.get('/api/v1/virtual-printer/status', () => {
         return HttpResponse.json({ running: false });
         return HttpResponse.json({ running: false });
+      }),
+      http.get('/api/v1/auth/status', () => {
+        return HttpResponse.json({ auth_enabled: false, requires_setup: false });
       })
       })
     );
     );
   });
   });

+ 4 - 1
frontend/src/__tests__/utils.tsx

@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { BrowserRouter } from 'react-router-dom';
 import { BrowserRouter } from 'react-router-dom';
 import { ThemeProvider } from '../contexts/ThemeContext';
 import { ThemeProvider } from '../contexts/ThemeContext';
 import { ToastProvider } from '../contexts/ToastContext';
 import { ToastProvider } from '../contexts/ToastContext';
+import { AuthProvider } from '../contexts/AuthContext';
 
 
 // Create a new QueryClient for each test
 // Create a new QueryClient for each test
 function createTestQueryClient() {
 function createTestQueryClient() {
@@ -36,7 +37,9 @@ function AllProviders({ children }: AllProvidersProps) {
     <QueryClientProvider client={queryClient}>
     <QueryClientProvider client={queryClient}>
       <BrowserRouter>
       <BrowserRouter>
         <ThemeProvider>
         <ThemeProvider>
-          <ToastProvider>{children}</ToastProvider>
+          <AuthProvider>
+            <ToastProvider>{children}</ToastProvider>
+          </AuthProvider>
         </ThemeProvider>
         </ThemeProvider>
       </BrowserRouter>
       </BrowserRouter>
     </QueryClientProvider>
     </QueryClientProvider>

+ 121 - 4
frontend/src/api/client.ts

@@ -1,19 +1,47 @@
 const API_BASE = '/api/v1';
 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>(
 async function request<T>(
   endpoint: string,
   endpoint: string,
   options: RequestInit = {}
   options: RequestInit = {}
 ): Promise<T> {
 ): 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}`, {
   const response = await fetch(`${API_BASE}${endpoint}`, {
     ...options,
     ...options,
     cache: 'no-store', // Prevent browser caching of API responses
     cache: 'no-store', // Prevent browser caching of API responses
-    headers: {
-      'Content-Type': 'application/json',
-      ...options.headers,
-    },
+    headers,
   });
   });
 
 
   if (!response.ok) {
   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 error = await response.json().catch(() => ({}));
     const detail = error.detail;
     const detail = error.detail;
     const message = typeof detail === 'string'
     const message = typeof detail === 'string'
@@ -1375,8 +1403,97 @@ export interface ExternalLinkUpdate {
   icon?: string;
   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
 // API functions
 export const api = {
 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
   // Printers
   getPrinters: () => request<Printer[]>('/printers/'),
   getPrinters: () => request<Printer[]>('/printers/'),
   getPrinter: (id: number) => request<Printer>(`/printers/${id}`),
   getPrinter: (id: number) => request<Printer>(`/printers/${id}`),

+ 33 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 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 { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -9,6 +9,7 @@ import { useQuery } from '@tanstack/react-query';
 import { api, supportApi, pendingUploadsApi } from '../api/client';
 import { api, supportApi, pendingUploadsApi } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { getIconByName } from './IconPicker';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
+import { useAuth } from '../contexts/AuthContext';
 
 
 interface NavItem {
 interface NavItem {
   id: string;
   id: string;
@@ -68,6 +69,7 @@ export function Layout() {
   const { mode, toggleMode } = useTheme();
   const { mode, toggleMode } = useTheme();
   const { t } = useTranslation();
   const { t } = useTranslation();
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
+  const { user, authEnabled, logout } = useAuth();
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
     const stored = localStorage.getItem('sidebarExpanded');
     const stored = localStorage.getItem('sidebarExpanded');
     return stored !== 'false';
     return stored !== 'false';
@@ -168,12 +170,20 @@ export function Layout() {
   const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
   const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
 
 
   // Compute the ordered sidebar: include stored order + any new items
   // Compute the ordered sidebar: include stored order + any new items
+  // Filter out 'settings' for users with 'user' role
   const orderedSidebarIds = (() => {
   const orderedSidebarIds = (() => {
     const result: string[] = [];
     const result: string[] = [];
     const seen = new Set<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
     // Add items in stored order
     for (const id of sidebarOrder) {
     for (const id of sidebarOrder) {
+      // Skip settings if user is not admin
+      if (hideSettings && id === 'settings') {
+        continue;
+      }
       if (navItemsMap.has(id) || extLinksMap.has(id)) {
       if (navItemsMap.has(id) || extLinksMap.has(id)) {
         result.push(id);
         result.push(id);
         seen.add(id);
         seen.add(id);
@@ -182,6 +192,10 @@ export function Layout() {
 
 
     // Add any new internal nav items not in stored order
     // Add any new internal nav items not in stored order
     for (const item of defaultNavItems) {
     for (const item of defaultNavItems) {
+      // Skip settings if user is not admin
+      if (hideSettings && item.id === 'settings') {
+        continue;
+      }
       if (!seen.has(item.id)) {
       if (!seen.has(item.id)) {
         result.push(item.id);
         result.push(item.id);
         seen.add(item.id);
         seen.add(item.id);
@@ -565,6 +579,15 @@ export function Layout() {
                 >
                 >
                   {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                   {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 </button>
                 </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>
               {/* Bottom row: version */}
               {/* Bottom row: version */}
               <div className="flex items-center justify-center gap-2">
               <div className="flex items-center justify-center gap-2">
@@ -642,6 +665,15 @@ export function Layout() {
               >
               >
                 {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
               </button>
               </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>
           )}
           )}
         </div>
         </div>

+ 116 - 0
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<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 - 0
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 (
+    <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>
+  );
+}

+ 242 - 3
frontend/src/pages/SettingsPage.tsx

@@ -1,7 +1,9 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 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 { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
 import { api } from '../api/client';
 import { api } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
 import { formatDateOnly } from '../utils/date';
 import { formatDateOnly } from '../utils/date';
 import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus } from '../api/client';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Card, CardContent, CardHeader } from '../components/Card';
@@ -29,8 +31,10 @@ import { Palette } from 'lucide-react';
 
 
 export function SettingsPage() {
 export function SettingsPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
+  const navigate = useNavigate();
   const { t, i18n } = useTranslation();
   const { t, i18n } = useTranslation();
   const { showToast, showPersistentToast, dismissToast } = useToast();
   const { showToast, showPersistentToast, dismissToast } = useToast();
+  const { authEnabled, user, refreshAuth } = useAuth();
   const {
   const {
     mode,
     mode,
     darkStyle, darkBackground, darkAccent,
     darkStyle, darkBackground, darkAccent,
@@ -46,7 +50,7 @@ export function SettingsPage() {
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [showLogViewer, setShowLogViewer] = useState(false);
   const [defaultView, setDefaultViewState] = useState<string>(getDefaultView());
   const [defaultView, setDefaultViewState] = useState<string>(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 [showCreateAPIKey, setShowCreateAPIKey] = useState(false);
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyName, setNewAPIKeyName] = useState('');
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
   const [newAPIKeyPermissions, setNewAPIKeyPermissions] = useState({
@@ -66,6 +70,7 @@ export function SettingsPage() {
   const [showRestoreModal, setShowRestoreModal] = useState(false);
   const [showRestoreModal, setShowRestoreModal] = useState(false);
   const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
   const [showTelemetryInfo, setShowTelemetryInfo] = useState(false);
   const [showReleaseNotes, setShowReleaseNotes] = useState(false);
   const [showReleaseNotes, setShowReleaseNotes] = useState(false);
+  const [showDisableAuthConfirm, setShowDisableAuthConfirm] = useState(false);
 
 
   // Home Assistant test connection state
   // Home Assistant test connection state
   const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
   const [haTestResult, setHaTestResult] = useState<{ success: boolean; message: string | null; error: string | null } | null>(null);
@@ -474,7 +479,7 @@ export function SettingsPage() {
       </div>
       </div>
 
 
       {/* Tab Navigation */}
       {/* Tab Navigation */}
-      <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary">
+      <div className="flex gap-1 mb-6 border-b border-bambu-dark-tertiary overflow-x-auto">
         <button
         <button
           onClick={() => setActiveTab('general')}
           onClick={() => setActiveTab('general')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px ${
@@ -568,6 +573,20 @@ export function SettingsPage() {
           Virtual Printer
           Virtual Printer
           <span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
           <span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
         </button>
         </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>
       </div>
 
 
       {/* General Tab */}
       {/* General Tab */}
@@ -2866,6 +2885,226 @@ export function SettingsPage() {
           </Card>
           </Card>
         </div>
         </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>
     </div>
   );
   );
 }
 }

+ 161 - 0
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 (
+    <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 - 0
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<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 - 0
frontend/tailwind.config.js

@@ -16,6 +16,7 @@ export default {
           dark: '#1a1a1a',
           dark: '#1a1a1a',
           'dark-secondary': '#2d2d2d',
           'dark-secondary': '#2d2d2d',
           'dark-tertiary': '#3d3d3d',
           'dark-tertiary': '#3d3d3d',
+          card: '#2d2d2d', // Same as dark-secondary for card backgrounds
           gray: '#808080',
           gray: '#808080',
           'gray-light': '#a0a0a0',
           'gray-light': '#a0a0a0',
           'gray-dark': '#4a4a4a',
           'gray-dark': '#4a4a4a',

+ 4 - 0
requirements.txt

@@ -37,6 +37,10 @@ qrcode[pil]>=7.4.0
 # System monitoring
 # System monitoring
 psutil>=6.0.0
 psutil>=6.0.0
 
 
+# Authentication
+python-jose[cryptography]>=3.3.0
+passlib[bcrypt]>=1.7.4
+
 # Development
 # Development
 pytest>=8.0.0
 pytest>=8.0.0
 pytest-asyncio>=0.23.0
 pytest-asyncio>=0.23.0

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BK0uiRXP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BmODu1qm.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-COZJGA_d.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DFo1_Rau.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DFo1_Rau.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BmODu1qm.css">
+    <script type="module" crossorigin src="/assets/index-BK0uiRXP.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-COZJGA_d.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff