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