diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3e5d379..894f6dc9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.6-final] - Not released ### New Features +- **Group-Based Permissions** - Granular access control with user groups: + - Create custom groups with specific permissions (50+ granular permissions) + - Default system groups: Administrators (full access), Operators (control printers), Viewers (read-only) + - Users can belong to multiple groups with additive permissions + - Permission-based UI: buttons/features disabled when user lacks permission + - Groups management page in Settings → Users → Groups tab + - Change password: users can change their own password from sidebar + - Included in backup/restore +- **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156): + - Checkbox option when uploading STL files to generate thumbnails automatically + - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button + - Individual file thumbnail generation via context menu (three-dot menu) + - Works with ZIP extraction (generates thumbnails for all STL files in archive) + - Uses trimesh and matplotlib for 3D rendering with Bambu green color theme + - Thumbnails auto-refresh in UI after generation + - Graceful handling of complex/invalid STL files - **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164): - All-in-one page at `/overlay/:printerId` combining camera feed with status overlay - Real-time print progress, ETA, layer count, and filename display diff --git a/README.md b/README.md index f14d785dd..2e5c1a24c 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,10 @@ ### 🔒 Optional Authentication - Enable/disable authentication any time -- Role-based access (Admin/User) +- Group-based permissions (50+ granular permissions) +- Default groups: Administrators, Operators, Viewers - JWT tokens with secure password hashing -- User management (create, edit, delete) +- User management (create, edit, delete, groups) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index a46e04ab4..f73356994 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -3,6 +3,7 @@ from datetime import timedelta from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from backend.app.core.auth import ( ACCESS_TOKEN_EXPIRE_MINUTES, @@ -13,9 +14,25 @@ from backend.app.core.auth import ( get_user_by_username, ) from backend.app.core.database import get_db +from backend.app.models.group import Group 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 +from backend.app.schemas.auth import GroupBrief, LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse + + +def _user_to_response(user: User) -> UserResponse: + """Convert a User model to UserResponse schema.""" + return UserResponse( + id=user.id, + username=user.username, + role=user.role, + is_active=user.is_active, + is_admin=user.is_admin, + groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups], + permissions=sorted(user.get_permissions()), + created_at=user.created_at.isoformat(), + ) + router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -126,6 +143,14 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)): role="admin", is_active=True, ) + + # Try to add user to Administrators group if it exists + admin_group_result = await db.execute(select(Group).where(Group.name == "Administrators")) + admin_group = admin_group_result.scalar_one_or_none() + if admin_group: + admin_user.groups.append(admin_group) + logger.info("Added new admin user to Administrators group") + db.add(admin_user) logger.info(f"Admin user added to session: {request.admin_username}") admin_created = True @@ -179,8 +204,12 @@ async def disable_auth( logger = logging.getLogger(__name__) + # Reload user with groups for proper is_admin check + result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups))) + user = result.scalar_one() + # Only admins can disable authentication - if current_user.role != "admin": + if not user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can disable authentication", @@ -189,7 +218,7 @@ async def disable_auth( try: await set_auth_enabled(db, False) await db.commit() - logger.info(f"Authentication disabled by admin user: {current_user.username}") + logger.info(f"Authentication disabled by admin user: {user.username}") return {"message": "Authentication disabled successfully", "auth_enabled": False} except Exception as e: await db.rollback() @@ -219,32 +248,30 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): headers={"WWW-Authenticate": "Bearer"}, ) + # Reload user with groups for proper permission calculation + result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups))) + user = result.scalar_one() + 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(), - ), + user=_user_to_response(user), ) @router.get("/me", response_model=UserResponse) -async def get_current_user_info(current_user: User = Depends(get_current_active_user)): +async def get_current_user_info( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +): """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(), - ) + # Reload user with groups for proper permission calculation + result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups))) + user = result.scalar_one() + return _user_to_response(user) @router.post("/logout") diff --git a/backend/app/api/routes/groups.py b/backend/app/api/routes/groups.py new file mode 100644 index 000000000..926489c08 --- /dev/null +++ b/backend/app/api/routes/groups.py @@ -0,0 +1,316 @@ +"""Group management API routes.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from backend.app.core.auth import RequirePermissionIfAuthEnabled +from backend.app.core.database import get_db +from backend.app.core.permissions import ( + ALL_PERMISSIONS, + PERMISSION_CATEGORIES, + Permission, +) +from backend.app.models.group import Group +from backend.app.models.user import User +from backend.app.schemas.group import ( + GroupCreate, + GroupDetailResponse, + GroupResponse, + GroupUpdate, + PermissionCategory, + PermissionInfo, + PermissionsListResponse, + UserBrief, +) + +router = APIRouter(prefix="/groups", tags=["groups"]) + + +def _permission_label(perm: Permission) -> str: + """Convert permission enum to human-readable label.""" + # e.g., "printers:read" -> "Read Printers" + parts = perm.value.split(":") + if len(parts) == 2: + resource, action = parts + resource = resource.replace("_", " ").title() + action = action.title() + return f"{action} {resource}" + return perm.value + + +@router.get("/permissions", response_model=PermissionsListResponse) +async def list_permissions( + _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ), +): + """List all available permissions organized by category.""" + categories = [] + for name, perms in PERMISSION_CATEGORIES.items(): + categories.append( + PermissionCategory( + name=name, + permissions=[PermissionInfo(value=p.value, label=_permission_label(p)) for p in perms], + ) + ) + return PermissionsListResponse( + categories=categories, + all_permissions=ALL_PERMISSIONS, + ) + + +@router.get("", response_model=list[GroupResponse]) +@router.get("/", response_model=list[GroupResponse]) +async def list_groups( + _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ), + db: AsyncSession = Depends(get_db), +): + """List all groups.""" + result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name)) + groups = result.scalars().all() + return [ + GroupResponse( + id=group.id, + name=group.name, + description=group.description, + permissions=group.permissions or [], + is_system=group.is_system, + user_count=len(group.users), + created_at=group.created_at, + updated_at=group.updated_at, + ) + for group in groups + ] + + +@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED) +@router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED) +async def create_group( + group_data: GroupCreate, + _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE), + db: AsyncSession = Depends(get_db), +): + """Create a new group.""" + # Check if group name already exists + existing = await db.execute(select(Group).where(Group.name == group_data.name)) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Group name already exists", + ) + + # Validate permissions + invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS] + if invalid_perms: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid permissions: {', '.join(invalid_perms)}", + ) + + group = Group( + name=group_data.name, + description=group_data.description, + permissions=group_data.permissions, + is_system=False, # User-created groups are not system groups + ) + db.add(group) + await db.commit() + await db.refresh(group) + + return GroupResponse( + id=group.id, + name=group.name, + description=group.description, + permissions=group.permissions or [], + is_system=group.is_system, + user_count=0, + created_at=group.created_at, + updated_at=group.updated_at, + ) + + +@router.get("/{group_id}", response_model=GroupDetailResponse) +async def get_group( + group_id: int, + _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ), + db: AsyncSession = Depends(get_db), +): + """Get a group by ID with user list.""" + result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users))) + group = result.scalar_one_or_none() + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Group not found", + ) + + return GroupDetailResponse( + id=group.id, + name=group.name, + description=group.description, + permissions=group.permissions or [], + is_system=group.is_system, + user_count=len(group.users), + created_at=group.created_at, + updated_at=group.updated_at, + users=[UserBrief(id=u.id, username=u.username, is_active=u.is_active) for u in group.users], + ) + + +@router.patch("/{group_id}", response_model=GroupResponse) +async def update_group( + group_id: int, + group_data: GroupUpdate, + _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE), + db: AsyncSession = Depends(get_db), +): + """Update a group.""" + result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users))) + group = result.scalar_one_or_none() + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Group not found", + ) + + # Check if updating name to one that already exists + if group_data.name is not None and group_data.name != group.name: + existing = await db.execute(select(Group).where(Group.name == group_data.name, Group.id != group_id)) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Group name already exists", + ) + # System groups cannot have their name changed + if group.is_system: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot rename system groups", + ) + group.name = group_data.name + + if group_data.description is not None: + group.description = group_data.description + + if group_data.permissions is not None: + # Validate permissions + invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS] + if invalid_perms: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid permissions: {', '.join(invalid_perms)}", + ) + group.permissions = group_data.permissions + + await db.commit() + await db.refresh(group) + + return GroupResponse( + id=group.id, + name=group.name, + description=group.description, + permissions=group.permissions or [], + is_system=group.is_system, + user_count=len(group.users), + created_at=group.created_at, + updated_at=group.updated_at, + ) + + +@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_group( + group_id: int, + _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE), + db: AsyncSession = Depends(get_db), +): + """Delete a group (non-system groups only).""" + result = await db.execute(select(Group).where(Group.id == group_id)) + group = result.scalar_one_or_none() + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Group not found", + ) + + if group.is_system: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot delete system groups", + ) + + await db.delete(group) + await db.commit() + + +@router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def add_user_to_group( + group_id: int, + user_id: int, + _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE), + db: AsyncSession = Depends(get_db), +): + """Add a user to a group.""" + # Get group with users + result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users))) + group = result.scalar_one_or_none() + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Group not found", + ) + + # Get user + user_result = await db.execute(select(User).where(User.id == user_id)) + user = user_result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + # Check if user is already in group + if user in group.users: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User is already in this group", + ) + + group.users.append(user) + await db.commit() + + +@router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_user_from_group( + group_id: int, + user_id: int, + _: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE), + db: AsyncSession = Depends(get_db), +): + """Remove a user from a group.""" + # Get group with users + result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users))) + group = result.scalar_one_or_none() + if not group: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Group not found", + ) + + # Get user + user_result = await db.execute(select(User).where(User.id == user_id)) + user = user_result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + # Check if user is in group + if user not in group.users: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="User is not in this group", + ) + + group.users.remove(user) + await db.commit() diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 811272c66..bc4c128b8 100644 --- a/backend/app/api/routes/printers.py +++ b/backend/app/api/routes/printers.py @@ -8,9 +8,10 @@ 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.auth import RequirePermissionIfAuthEnabled from backend.app.core.config import settings from backend.app.core.database import get_db +from backend.app.core.permissions import Permission from backend.app.models.printer import Printer from backend.app.models.slot_preset import SlotPresetMapping from backend.app.schemas.printer import ( @@ -38,7 +39,10 @@ router = APIRouter(prefix="/printers", tags=["printers"]) @router.get("/", response_model=list[PrinterResponse]) -async def list_printers(db: AsyncSession = Depends(get_db)): +async def list_printers( + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), + db: AsyncSession = Depends(get_db), +): """List all configured printers.""" result = await db.execute(select(Printer).order_by(Printer.name)) return list(result.scalars().all()) @@ -47,8 +51,8 @@ async def list_printers(db: AsyncSession = Depends(get_db)): @router.post("/", response_model=PrinterResponse) async def create_printer( printer_data: PrinterCreate, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE), db: AsyncSession = Depends(get_db), - _current_user=RequireAdminIfAuthEnabled(), ): """Add a new printer.""" # Check if serial number already exists @@ -69,7 +73,9 @@ async def create_printer( @router.get("/usb-cameras") -async def list_usb_cameras(): +async def list_usb_cameras( + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), +): """List available USB cameras connected to the system. Returns a list of detected V4L2 video devices with their info. @@ -85,7 +91,11 @@ async def list_usb_cameras(): @router.get("/{printer_id}", response_model=PrinterResponse) -async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)): +async def get_printer( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), + db: AsyncSession = Depends(get_db), +): """Get a specific printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -98,8 +108,8 @@ async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)): async def update_printer( printer_id: int, printer_data: PrinterUpdate, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), db: AsyncSession = Depends(get_db), - _current_user=RequireAdminIfAuthEnabled(), ): """Update a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) @@ -143,8 +153,8 @@ async def update_printer( async def delete_printer( printer_id: int, delete_archives: bool = True, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE), db: AsyncSession = Depends(get_db), - _current_user=RequireAdminIfAuthEnabled(), ): """Delete a printer. @@ -191,7 +201,11 @@ async def delete_printer( @router.get("/{printer_id}/status", response_model=PrinterStatus) -async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)): +async def get_printer_status( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), + db: AsyncSession = Depends(get_db), +): """Get real-time status of a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -431,7 +445,11 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db) @router.post("/{printer_id}/refresh-status") -async def refresh_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)): +async def refresh_printer_status( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), + db: AsyncSession = Depends(get_db), +): """Request a full status refresh from the printer (sends pushall command).""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -446,7 +464,11 @@ async def refresh_printer_status(printer_id: int, db: AsyncSession = Depends(get @router.post("/{printer_id}/connect") -async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)): +async def connect_printer( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + db: AsyncSession = Depends(get_db), +): """Manually connect to a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -458,7 +480,11 @@ async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)): @router.post("/{printer_id}/disconnect") -async def disconnect_printer(printer_id: int, db: AsyncSession = Depends(get_db)): +async def disconnect_printer( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + db: AsyncSession = Depends(get_db), +): """Manually disconnect from a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -474,6 +500,7 @@ async def test_printer_connection( ip_address: str, serial_number: str, access_code: str, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE), ): """Test connection to a printer without saving.""" result = await printer_manager.test_connection( @@ -492,6 +519,7 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {} async def get_printer_cover( printer_id: int, view: str | None = None, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get the cover image for the current print job. @@ -679,6 +707,7 @@ async def get_printer_cover( async def list_printer_files( printer_id: int, path: str = "/", + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES), db: AsyncSession = Depends(get_db), ): """List files on the printer at the specified path.""" @@ -703,6 +732,7 @@ async def list_printer_files( async def download_printer_file( printer_id: int, path: str, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES), db: AsyncSession = Depends(get_db), ): """Download a file from the printer.""" @@ -743,6 +773,7 @@ async def download_printer_file( async def download_printer_files_as_zip( printer_id: int, request: dict, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES), db: AsyncSession = Depends(get_db), ): """Download multiple files from the printer as a ZIP archive.""" @@ -787,6 +818,7 @@ async def download_printer_files_as_zip( async def delete_printer_file( printer_id: int, path: str, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES), db: AsyncSession = Depends(get_db), ): """Delete a file from the printer.""" @@ -805,6 +837,7 @@ async def delete_printer_file( @router.get("/{printer_id}/storage") async def get_printer_storage( printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get storage information from the printer.""" @@ -824,7 +857,11 @@ async def get_printer_storage( @router.post("/{printer_id}/logging/enable") -async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)): +async def enable_mqtt_logging( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + db: AsyncSession = Depends(get_db), +): """Enable MQTT message logging for a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -839,7 +876,11 @@ async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db @router.post("/{printer_id}/logging/disable") -async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)): +async def disable_mqtt_logging( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + db: AsyncSession = Depends(get_db), +): """Disable MQTT message logging for a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -854,7 +895,11 @@ async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_d @router.get("/{printer_id}/logging") -async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)): +async def get_mqtt_logs( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), + db: AsyncSession = Depends(get_db), +): """Get MQTT message logs for a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -877,7 +922,11 @@ async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)): @router.delete("/{printer_id}/logging") -async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)): +async def clear_mqtt_logs( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + db: AsyncSession = Depends(get_db), +): """Clear MQTT message logs for a printer.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -900,6 +949,7 @@ async def set_print_option( enabled: bool, print_halt: bool = True, sensitivity: str = "medium", + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Set an AI detection / print option on the printer. @@ -972,6 +1022,7 @@ async def start_calibration( motor_noise: bool = False, nozzle_offset: bool = False, high_temp_heatbed: bool = False, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Start printer calibration with selected options. @@ -1027,6 +1078,7 @@ async def start_calibration( @router.get("/{printer_id}/slot-presets") async def get_slot_presets( printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get all saved slot-to-preset mappings for a printer.""" @@ -1049,6 +1101,7 @@ async def get_slot_preset( printer_id: int, ams_id: int, tray_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get the saved preset for a specific slot.""" @@ -1079,6 +1132,7 @@ async def save_slot_preset( tray_id: int, preset_id: str, preset_name: str, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), db: AsyncSession = Depends(get_db), ): """Save a preset mapping for a specific slot.""" @@ -1128,6 +1182,7 @@ async def delete_slot_preset( printer_id: int, ams_id: int, tray_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE), db: AsyncSession = Depends(get_db), ): """Delete a saved preset mapping for a slot.""" @@ -1164,6 +1219,7 @@ async def configure_ams_slot( kprofile_filament_id: str = Query(""), kprofile_setting_id: str = Query(""), k_value: float = Query(0.0), + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), ): """Configure an AMS slot with a specific filament setting and K profile. @@ -1363,7 +1419,11 @@ async def debug_simulate_print_complete( @router.post("/{printer_id}/print/stop") -async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)): +async def stop_print( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + db: AsyncSession = Depends(get_db), +): """Stop/cancel the current print job.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -1382,7 +1442,11 @@ async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)): @router.post("/{printer_id}/print/pause") -async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)): +async def pause_print( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + db: AsyncSession = Depends(get_db), +): """Pause the current print job.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -1401,7 +1465,11 @@ async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)): @router.post("/{printer_id}/print/resume") -async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)): +async def resume_print( + printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), + db: AsyncSession = Depends(get_db), +): """Resume a paused print job.""" result = await db.execute(select(Printer).where(Printer.id == printer_id)) printer = result.scalar_one_or_none() @@ -1423,6 +1491,7 @@ async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)): async def set_chamber_light( printer_id: int, on: bool = Query(..., description="True to turn on, False to turn off"), + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Turn the chamber light on or off.""" @@ -1446,6 +1515,7 @@ async def set_chamber_light( async def get_printable_objects( printer_id: int, reload: bool = False, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Get the list of printable objects for the current print. @@ -1543,6 +1613,7 @@ async def get_printable_objects( async def skip_objects( printer_id: int, object_ids: list[int], + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Skip specific objects during the current print. @@ -1597,6 +1668,7 @@ async def refresh_ams_slot( printer_id: int, ams_id: int, slot_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL), db: AsyncSession = Depends(get_db), ): """Re-read RFID for an AMS slot (triggers filament info refresh).""" @@ -1619,6 +1691,7 @@ async def refresh_ams_slot( @router.get("/{printer_id}/runtime-debug") async def get_runtime_debug( printer_id: int, + _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ), db: AsyncSession = Depends(get_db), ): """Debug endpoint: Get runtime tracking status for a printer.""" diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 6810be969..5b0a3dd45 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -16,6 +16,7 @@ from backend.app.models.archive import PrintArchive from backend.app.models.external_link import ExternalLink from backend.app.models.filament import Filament from backend.app.models.github_backup import GitHubBackupConfig +from backend.app.models.group import Group from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance from backend.app.models.notification import NotificationProvider from backend.app.models.notification_template import NotificationTemplate @@ -259,6 +260,7 @@ async def export_backup( include_users: bool = Query( False, description="Include users (passwords not exported - users will need new passwords)" ), + include_groups: bool = Query(False, description="Include groups and user-group assignments"), include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"), ): """Export selected data as JSON backup.""" @@ -876,11 +878,28 @@ async def export_backup( "username": user.username, "role": user.role, "is_active": user.is_active, + "groups": [g.name for g in user.groups], # password_hash intentionally not exported for security } ) backup["included"].append("users") + # Groups (permission groups) + if include_groups: + result = await db.execute(select(Group)) + groups = result.scalars().all() + backup["groups"] = [] + for group in groups: + backup["groups"].append( + { + "name": group.name, + "description": group.description, + "permissions": group.permissions, + "is_system": group.is_system, + } + ) + backup["included"].append("groups") + # GitHub backup configuration if include_github_backup: result = await db.execute(select(GitHubBackupConfig).limit(1)) @@ -998,6 +1017,7 @@ async def import_backup( "projects": 0, "pending_uploads": 0, "users": 0, + "groups": 0, "github_backup": 0, } skipped = { @@ -1013,6 +1033,7 @@ async def import_backup( "projects": 0, "pending_uploads": 0, "users": 0, + "groups": 0, "github_backup": 0, } skipped_details = { @@ -1026,6 +1047,7 @@ async def import_backup( "projects": [], "pending_uploads": [], "users": [], + "groups": [], } # Restore settings (always overwrites) @@ -2036,6 +2058,39 @@ async def import_backup( } ) + # Restore groups (before users, so groups exist for assignment) + if "groups" in backup: + for group_data in backup["groups"]: + result = await db.execute(select(Group).where(Group.name == group_data["name"])) + existing = result.scalar_one_or_none() + if existing: + if overwrite and not existing.is_system: + # Update non-system groups + existing.description = group_data.get("description") + existing.permissions = group_data.get("permissions", []) + restored["groups"] += 1 + else: + skipped["groups"] += 1 + skipped_details["groups"].append(group_data["name"]) + else: + group = Group( + name=group_data["name"], + description=group_data.get("description"), + permissions=group_data.get("permissions", []), + is_system=group_data.get("is_system", False), + ) + db.add(group) + restored["groups"] += 1 + + # Flush to ensure groups are persisted before user assignment + await db.flush() + + # Build group name to object lookup for user assignment + group_name_to_obj: dict[str, Group] = {} + result = await db.execute(select(Group)) + for g in result.scalars().all(): + group_name_to_obj[g.name] = g + # Restore users (note: passwords not included in backup - users will need new passwords) # Users are skipped by default since they have no passwords; admin must recreate them new_users: list[str] = [] @@ -2049,6 +2104,10 @@ async def import_backup( if overwrite: existing.role = user_data.get("role", "user") existing.is_active = user_data.get("is_active", True) + # Assign groups if provided + group_names = user_data.get("groups", []) + if group_names: + existing.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj] # Don't change password - keep existing restored["users"] += 1 else: @@ -2066,6 +2125,10 @@ async def import_backup( role=user_data.get("role", "user"), is_active=user_data.get("is_active", True), ) + # Assign groups if provided + group_names = user_data.get("groups", []) + if group_names: + user.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj] db.add(user) restored["users"] += 1 new_users.append(f"{user_data['username']} (temp password: {temp_password})") diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 4b6c514ac..ecbcf2426 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -1,44 +1,57 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload -from backend.app.core.auth import RequireAdmin, get_password_hash +from backend.app.core.auth import ( + RequirePermissionIfAuthEnabled, + get_current_user_optional, + get_password_hash, + verify_password, +) from backend.app.core.database import get_db +from backend.app.core.permissions import Permission +from backend.app.models.group import Group from backend.app.models.user import User -from backend.app.schemas.auth import UserCreate, UserResponse, UserUpdate +from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate router = APIRouter(prefix="/users", tags=["users"]) +def _user_to_response(user: User) -> UserResponse: + """Convert a User model to UserResponse schema.""" + return UserResponse( + id=user.id, + username=user.username, + role=user.role, + is_active=user.is_active, + is_admin=user.is_admin, + groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups], + permissions=sorted(user.get_permissions()), + created_at=user.created_at.isoformat(), + ) + + @router.get("", response_model=list[UserResponse]) @router.get("/", response_model=list[UserResponse]) async def list_users( - current_user: User = RequireAdmin(), + _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ), db: AsyncSession = Depends(get_db), ): - """List all users (admin only).""" - result = await db.execute(select(User).order_by(User.created_at)) + """List all users.""" + result = await db.execute(select(User).options(selectinload(User.groups)).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 - ] + return [_user_to_response(user) 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(), + _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE), db: AsyncSession = Depends(get_db), ): - """Create a new user (admin only).""" + """Create a new user.""" # 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(): @@ -60,27 +73,33 @@ async def create_user( role=user_data.role, is_active=True, ) + + # Handle group assignments + if user_data.group_ids: + groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids))) + groups = groups_result.scalars().all() + if len(groups) != len(user_data.group_ids): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="One or more group IDs are invalid", + ) + new_user.groups = list(groups) + 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(), - ) + return _user_to_response(new_user) @router.get("/{user_id}", response_model=UserResponse) async def get_user( user_id: int, - current_user: User = RequireAdmin(), + _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ), db: AsyncSession = Depends(get_db), ): - """Get a user by ID (admin only).""" - result = await db.execute(select(User).where(User.id == user_id)) + """Get a user by ID.""" + result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups))) user = result.scalar_one_or_none() if not user: raise HTTPException( @@ -88,24 +107,18 @@ async def get_user( 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(), - ) + return _user_to_response(user) @router.patch("/{user_id}", response_model=UserResponse) async def update_user( user_id: int, user_data: UserUpdate, - current_user: User = RequireAdmin(), + _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE), db: AsyncSession = Depends(get_db), ): - """Update a user (admin only).""" - result = await db.execute(select(User).where(User.id == user_id)) + """Update a user.""" + result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups))) user = result.scalar_one_or_none() if not user: raise HTTPException( @@ -114,10 +127,21 @@ async def update_user( ) # Prevent deactivating the last admin - if user_data.is_active is False and user.role == "admin": + if user_data.is_active is False and user.is_admin: + # Count admins by role or Administrators group membership 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: + role_admins = admin_count_result.scalars().all() + + # Also check for users in Administrators group + admin_group_result = await db.execute( + select(Group).where(Group.name == "Administrators").options(selectinload(Group.users)) + ) + admin_group = admin_group_result.scalar_one_or_none() + group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active] + + # Combine unique admins + all_admins = {u.id for u in role_admins} | {u.id for u in group_admins} + if len(all_admins) <= 1 and user.id in all_admins: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot deactivate the last admin user", @@ -157,26 +181,31 @@ async def update_user( if user_data.is_active is not None: user.is_active = user_data.is_active + # Handle group assignments + if user_data.group_ids is not None: + groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids))) + groups = groups_result.scalars().all() + if len(groups) != len(user_data.group_ids): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="One or more group IDs are invalid", + ) + user.groups = list(groups) + 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(), - ) + return _user_to_response(user) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_user( user_id: int, - current_user: User = RequireAdmin(), + current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE), db: AsyncSession = Depends(get_db), ): - """Delete a user (admin only).""" - result = await db.execute(select(User).where(User.id == user_id)) + """Delete a user.""" + result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups))) user = result.scalar_one_or_none() if not user: raise HTTPException( @@ -185,17 +214,28 @@ async def delete_user( ) # Prevent deleting the last admin - if user.role == "admin": + if user.is_admin: + # Count admins by role or Administrators group membership 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: + other_role_admins = admin_count_result.scalars().all() + + # Also check for users in Administrators group + admin_group_result = await db.execute( + select(Group).where(Group.name == "Administrators").options(selectinload(Group.users)) + ) + admin_group = admin_group_result.scalar_one_or_none() + other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active] + + # Combine unique admins + all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins} + if len(all_other_admins) == 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: + # Prevent deleting yourself (only if auth is enabled and we have a current user) + if current_user and user.id == current_user.id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete your own account", @@ -203,3 +243,46 @@ async def delete_user( await db.delete(user) await db.commit() + + +@router.post("/me/change-password", response_model=dict) +async def change_own_password( + password_data: ChangePasswordRequest, + current_user: User | None = Depends(get_current_user_optional), + db: AsyncSession = Depends(get_db), +): + """Change the current user's password. Requires current password verification.""" + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required to change password", + ) + + # Verify current password + if not verify_password(password_data.current_password, current_user.password_hash): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Current password is incorrect", + ) + + # Validate new password + if len(password_data.new_password) < 6: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="New password must be at least 6 characters", + ) + + # Fetch user from this session to ensure changes are persisted + result = await db.execute(select(User).where(User.id == current_user.id)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + # Update password + user.password_hash = get_password_hash(password_data.new_password) + await db.commit() + + return {"message": "Password changed successfully"} diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index fb3c1c367..f42a279a7 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -11,8 +11,10 @@ from jwt.exceptions import PyJWTError as JWTError from passlib.context import CryptContext from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from backend.app.core.database import async_session, get_db +from backend.app.core.permissions import Permission from backend.app.models.api_key import APIKey from backend.app.models.settings import Settings from backend.app.models.user import User @@ -60,8 +62,8 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s 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)) + """Get a user by username with groups loaded for permission checks.""" + result = await db.execute(select(User).where(User.username == username).options(selectinload(User.groups))) return result.scalar_one_or_none() @@ -347,3 +349,124 @@ def RequireAdmin(): def RequireAdminIfAuthEnabled(): """Dependency that requires admin role if auth is enabled.""" return Depends(require_admin_if_auth_enabled()) + + +def require_permission(*permissions: str | Permission): + """Dependency factory that requires user to have ALL specified permissions. + + Args: + *permissions: Permission strings or Permission enum values to require + + Returns: + A dependency function that validates permissions + """ + # Convert Permission enums to strings + perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions] + + async def permission_checker( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None, + ) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + if credentials is None: + raise credentials_exception + + 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 or not user.is_active: + raise credentials_exception + + if not user.has_all_permissions(*perm_strings): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permissions: {', '.join(perm_strings)}", + ) + return user + + return permission_checker + + +def require_permission_if_auth_enabled(*permissions: str | Permission): + """Dependency factory that checks permissions only if auth is enabled. + + This provides backward compatibility - when auth is disabled, all access is allowed. + + Args: + *permissions: Permission strings or Permission enum values to require + + Returns: + A dependency function that validates permissions if auth is enabled + """ + # Convert Permission enums to strings + perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions] + + async def permission_checker( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None, + ) -> User | None: + async with async_session() as db: + auth_enabled = await is_auth_enabled(db) + if not auth_enabled: + return None # Auth disabled, allow access + + 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"}, + ) + + if not user.has_all_permissions(*perm_strings): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permissions: {', '.join(perm_strings)}", + ) + return user + + return permission_checker + + +def RequirePermission(*permissions: str | Permission): + """Convenience dependency that requires ALL specified permissions.""" + return Depends(require_permission(*permissions)) + + +def RequirePermissionIfAuthEnabled(*permissions: str | Permission): + """Convenience dependency that requires permissions if auth is enabled.""" + return Depends(require_permission_if_auth_enabled(*permissions)) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 2f74cd8c6..e3fca7c70 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -39,6 +39,7 @@ async def init_db(): external_link, filament, github_backup, + group, kprofile_note, library, maintenance, @@ -62,6 +63,9 @@ async def init_db(): # Seed default notification templates await seed_notification_templates() + # Seed default groups and migrate existing users + await seed_default_groups() + async def run_migrations(conn): """Add new columns to existing tables if they don't exist.""" @@ -894,6 +898,41 @@ async def run_migrations(conn): except Exception: pass + # Migration: Create groups table for permission-based access control + try: + await conn.execute( + text(""" + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description VARCHAR(500), + permissions JSON, + is_system BOOLEAN NOT NULL DEFAULT 0, + 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_groups_name ON groups(name)")) + except Exception: + pass + + # Migration: Create user_groups association table + try: + await conn.execute( + text(""" + CREATE TABLE IF NOT EXISTS user_groups ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + PRIMARY KEY (user_id, group_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE + ) + """) + ) + except Exception: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" @@ -931,3 +970,70 @@ async def seed_notification_templates(): session.add(template) await session.commit() + + +async def seed_default_groups(): + """Seed default groups and migrate existing users to appropriate groups. + + Creates the default system groups (Administrators, Operators, Viewers) if they + don't exist, then migrates existing users: + - Users with role='admin' -> Administrators group + - Users with role='user' -> Operators group + """ + import logging + + from sqlalchemy import select + + from backend.app.core.permissions import DEFAULT_GROUPS + from backend.app.models.group import Group + from backend.app.models.user import User + + logger = logging.getLogger(__name__) + + async with async_session() as session: + # Get existing groups + result = await session.execute(select(Group.name)) + existing_groups = {row[0] for row in result.fetchall()} + + # Create default groups if they don't exist + groups_created = [] + for group_name, group_config in DEFAULT_GROUPS.items(): + if group_name not in existing_groups: + group = Group( + name=group_name, + description=group_config["description"], + permissions=group_config["permissions"], + is_system=group_config["is_system"], + ) + session.add(group) + groups_created.append(group_name) + logger.info(f"Created default group: {group_name}") + + await session.commit() + + # Migrate existing users to groups if they're not already in any group + if groups_created: + # Get the groups we need + admin_result = await session.execute(select(Group).where(Group.name == "Administrators")) + admin_group = admin_result.scalar_one_or_none() + + operators_result = await session.execute(select(Group).where(Group.name == "Operators")) + operators_group = operators_result.scalar_one_or_none() + + # Get all users + users_result = await session.execute(select(User)) + users = users_result.scalars().all() + + for user in users: + # Skip if user already has groups + if user.groups: + continue + + if user.role == "admin" and admin_group: + user.groups.append(admin_group) + logger.info(f"Migrated admin user '{user.username}' to Administrators group") + elif operators_group: + user.groups.append(operators_group) + logger.info(f"Migrated user '{user.username}' to Operators group") + + await session.commit() diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py new file mode 100644 index 000000000..e30c36888 --- /dev/null +++ b/backend/app/core/permissions.py @@ -0,0 +1,392 @@ +"""Permission definitions for the group-based access control system. + +This module defines all permissions using a string enum with `resource:action` naming. +Permissions are additive across groups - a user has all permissions from all their groups. +""" + +from enum import Enum + + +class Permission(str, Enum): + """All available permissions in the system. + + Permissions follow the pattern: resource:action + Actions typically include: read, create, update, delete, plus resource-specific actions. + """ + + # Printers + PRINTERS_READ = "printers:read" + PRINTERS_CREATE = "printers:create" + PRINTERS_UPDATE = "printers:update" + PRINTERS_DELETE = "printers:delete" + PRINTERS_CONTROL = "printers:control" # Start/stop/pause/resume prints + PRINTERS_FILES = "printers:files" # Send files to printer + + # Archives + ARCHIVES_READ = "archives:read" + ARCHIVES_CREATE = "archives:create" + ARCHIVES_UPDATE = "archives:update" + ARCHIVES_DELETE = "archives:delete" + ARCHIVES_REPRINT = "archives:reprint" # Reprint from archive + + # Queue + QUEUE_READ = "queue:read" + QUEUE_CREATE = "queue:create" + QUEUE_UPDATE = "queue:update" + QUEUE_DELETE = "queue:delete" + QUEUE_REORDER = "queue:reorder" + + # Library + LIBRARY_READ = "library:read" + LIBRARY_UPLOAD = "library:upload" + LIBRARY_UPDATE = "library:update" + LIBRARY_DELETE = "library:delete" + + # Projects + PROJECTS_READ = "projects:read" + PROJECTS_CREATE = "projects:create" + PROJECTS_UPDATE = "projects:update" + PROJECTS_DELETE = "projects:delete" + + # Filaments + FILAMENTS_READ = "filaments:read" + FILAMENTS_CREATE = "filaments:create" + FILAMENTS_UPDATE = "filaments:update" + FILAMENTS_DELETE = "filaments:delete" + + # Smart Plugs + SMART_PLUGS_READ = "smart_plugs:read" + SMART_PLUGS_CREATE = "smart_plugs:create" + SMART_PLUGS_UPDATE = "smart_plugs:update" + SMART_PLUGS_DELETE = "smart_plugs:delete" + SMART_PLUGS_CONTROL = "smart_plugs:control" # Turn on/off + + # Camera + CAMERA_VIEW = "camera:view" + + # Maintenance + MAINTENANCE_READ = "maintenance:read" + MAINTENANCE_CREATE = "maintenance:create" + MAINTENANCE_UPDATE = "maintenance:update" + MAINTENANCE_DELETE = "maintenance:delete" + + # K-Profiles + KPROFILES_READ = "kprofiles:read" + KPROFILES_CREATE = "kprofiles:create" + KPROFILES_UPDATE = "kprofiles:update" + KPROFILES_DELETE = "kprofiles:delete" + + # Notifications + NOTIFICATIONS_READ = "notifications:read" + NOTIFICATIONS_CREATE = "notifications:create" + NOTIFICATIONS_UPDATE = "notifications:update" + NOTIFICATIONS_DELETE = "notifications:delete" + + # Notification Templates + NOTIFICATION_TEMPLATES_READ = "notification_templates:read" + NOTIFICATION_TEMPLATES_UPDATE = "notification_templates:update" + + # External Links + EXTERNAL_LINKS_READ = "external_links:read" + EXTERNAL_LINKS_CREATE = "external_links:create" + EXTERNAL_LINKS_UPDATE = "external_links:update" + EXTERNAL_LINKS_DELETE = "external_links:delete" + + # Discovery (network scanning) + DISCOVERY_SCAN = "discovery:scan" + + # Firmware + FIRMWARE_READ = "firmware:read" + FIRMWARE_UPDATE = "firmware:update" + + # AMS History + AMS_HISTORY_READ = "ams_history:read" + + # Stats/Metrics + STATS_READ = "stats:read" + + # System Info + SYSTEM_READ = "system:read" + + # Settings (admin-level) + SETTINGS_READ = "settings:read" + SETTINGS_UPDATE = "settings:update" + SETTINGS_BACKUP = "settings:backup" + SETTINGS_RESTORE = "settings:restore" + + # GitHub Backup (admin-level) + GITHUB_BACKUP = "github:backup" + GITHUB_RESTORE = "github:restore" + + # Cloud Auth (admin-level) + CLOUD_AUTH = "cloud:auth" + + # API Keys (admin-level) + API_KEYS_READ = "api_keys:read" + API_KEYS_CREATE = "api_keys:create" + API_KEYS_UPDATE = "api_keys:update" + API_KEYS_DELETE = "api_keys:delete" + + # Users (admin-level) + USERS_READ = "users:read" + USERS_CREATE = "users:create" + USERS_UPDATE = "users:update" + USERS_DELETE = "users:delete" + + # Groups (admin-level) + GROUPS_READ = "groups:read" + GROUPS_CREATE = "groups:create" + GROUPS_UPDATE = "groups:update" + GROUPS_DELETE = "groups:delete" + + # WebSocket connection + WEBSOCKET_CONNECT = "websocket:connect" + + +# Permission categories for UI organization +PERMISSION_CATEGORIES = { + "Printers": [ + Permission.PRINTERS_READ, + Permission.PRINTERS_CREATE, + Permission.PRINTERS_UPDATE, + Permission.PRINTERS_DELETE, + Permission.PRINTERS_CONTROL, + Permission.PRINTERS_FILES, + ], + "Archives": [ + Permission.ARCHIVES_READ, + Permission.ARCHIVES_CREATE, + Permission.ARCHIVES_UPDATE, + Permission.ARCHIVES_DELETE, + Permission.ARCHIVES_REPRINT, + ], + "Queue": [ + Permission.QUEUE_READ, + Permission.QUEUE_CREATE, + Permission.QUEUE_UPDATE, + Permission.QUEUE_DELETE, + Permission.QUEUE_REORDER, + ], + "Library": [ + Permission.LIBRARY_READ, + Permission.LIBRARY_UPLOAD, + Permission.LIBRARY_UPDATE, + Permission.LIBRARY_DELETE, + ], + "Projects": [ + Permission.PROJECTS_READ, + Permission.PROJECTS_CREATE, + Permission.PROJECTS_UPDATE, + Permission.PROJECTS_DELETE, + ], + "Filaments": [ + Permission.FILAMENTS_READ, + Permission.FILAMENTS_CREATE, + Permission.FILAMENTS_UPDATE, + Permission.FILAMENTS_DELETE, + ], + "Smart Plugs": [ + Permission.SMART_PLUGS_READ, + Permission.SMART_PLUGS_CREATE, + Permission.SMART_PLUGS_UPDATE, + Permission.SMART_PLUGS_DELETE, + Permission.SMART_PLUGS_CONTROL, + ], + "Camera": [ + Permission.CAMERA_VIEW, + ], + "Maintenance": [ + Permission.MAINTENANCE_READ, + Permission.MAINTENANCE_CREATE, + Permission.MAINTENANCE_UPDATE, + Permission.MAINTENANCE_DELETE, + ], + "K-Profiles": [ + Permission.KPROFILES_READ, + Permission.KPROFILES_CREATE, + Permission.KPROFILES_UPDATE, + Permission.KPROFILES_DELETE, + ], + "Notifications": [ + Permission.NOTIFICATIONS_READ, + Permission.NOTIFICATIONS_CREATE, + Permission.NOTIFICATIONS_UPDATE, + Permission.NOTIFICATIONS_DELETE, + Permission.NOTIFICATION_TEMPLATES_READ, + Permission.NOTIFICATION_TEMPLATES_UPDATE, + ], + "External Links": [ + Permission.EXTERNAL_LINKS_READ, + Permission.EXTERNAL_LINKS_CREATE, + Permission.EXTERNAL_LINKS_UPDATE, + Permission.EXTERNAL_LINKS_DELETE, + ], + "Discovery": [ + Permission.DISCOVERY_SCAN, + ], + "Firmware": [ + Permission.FIRMWARE_READ, + Permission.FIRMWARE_UPDATE, + ], + "Stats & History": [ + Permission.AMS_HISTORY_READ, + Permission.STATS_READ, + ], + "System": [ + Permission.SYSTEM_READ, + ], + "Settings": [ + Permission.SETTINGS_READ, + Permission.SETTINGS_UPDATE, + Permission.SETTINGS_BACKUP, + Permission.SETTINGS_RESTORE, + ], + "Backup": [ + Permission.GITHUB_BACKUP, + Permission.GITHUB_RESTORE, + ], + "Cloud": [ + Permission.CLOUD_AUTH, + ], + "API Keys": [ + Permission.API_KEYS_READ, + Permission.API_KEYS_CREATE, + Permission.API_KEYS_UPDATE, + Permission.API_KEYS_DELETE, + ], + "User Management": [ + Permission.USERS_READ, + Permission.USERS_CREATE, + Permission.USERS_UPDATE, + Permission.USERS_DELETE, + Permission.GROUPS_READ, + Permission.GROUPS_CREATE, + Permission.GROUPS_UPDATE, + Permission.GROUPS_DELETE, + ], + "WebSocket": [ + Permission.WEBSOCKET_CONNECT, + ], +} + + +# All permissions as a list +ALL_PERMISSIONS = [p.value for p in Permission] + + +# Default group definitions +DEFAULT_GROUPS = { + "Administrators": { + "description": "Full access to all features and settings", + "permissions": ALL_PERMISSIONS, # All permissions + "is_system": True, + }, + "Operators": { + "description": "Can control printers, manage queue and archives, view settings", + "permissions": [ + # Printers - full control + Permission.PRINTERS_READ.value, + Permission.PRINTERS_CREATE.value, + Permission.PRINTERS_UPDATE.value, + Permission.PRINTERS_DELETE.value, + Permission.PRINTERS_CONTROL.value, + Permission.PRINTERS_FILES.value, + # Archives - full access + Permission.ARCHIVES_READ.value, + Permission.ARCHIVES_CREATE.value, + Permission.ARCHIVES_UPDATE.value, + Permission.ARCHIVES_DELETE.value, + Permission.ARCHIVES_REPRINT.value, + # Queue - full access + Permission.QUEUE_READ.value, + Permission.QUEUE_CREATE.value, + Permission.QUEUE_UPDATE.value, + Permission.QUEUE_DELETE.value, + Permission.QUEUE_REORDER.value, + # Library - full access + Permission.LIBRARY_READ.value, + Permission.LIBRARY_UPLOAD.value, + Permission.LIBRARY_UPDATE.value, + Permission.LIBRARY_DELETE.value, + # Projects - full access + Permission.PROJECTS_READ.value, + Permission.PROJECTS_CREATE.value, + Permission.PROJECTS_UPDATE.value, + Permission.PROJECTS_DELETE.value, + # Filaments - full access + Permission.FILAMENTS_READ.value, + Permission.FILAMENTS_CREATE.value, + Permission.FILAMENTS_UPDATE.value, + Permission.FILAMENTS_DELETE.value, + # Smart Plugs - full access + Permission.SMART_PLUGS_READ.value, + Permission.SMART_PLUGS_CREATE.value, + Permission.SMART_PLUGS_UPDATE.value, + Permission.SMART_PLUGS_DELETE.value, + Permission.SMART_PLUGS_CONTROL.value, + # Camera - view + Permission.CAMERA_VIEW.value, + # Maintenance - full access + Permission.MAINTENANCE_READ.value, + Permission.MAINTENANCE_CREATE.value, + Permission.MAINTENANCE_UPDATE.value, + Permission.MAINTENANCE_DELETE.value, + # K-Profiles - full access + Permission.KPROFILES_READ.value, + Permission.KPROFILES_CREATE.value, + Permission.KPROFILES_UPDATE.value, + Permission.KPROFILES_DELETE.value, + # Notifications - full access + Permission.NOTIFICATIONS_READ.value, + Permission.NOTIFICATIONS_CREATE.value, + Permission.NOTIFICATIONS_UPDATE.value, + Permission.NOTIFICATIONS_DELETE.value, + Permission.NOTIFICATION_TEMPLATES_READ.value, + Permission.NOTIFICATION_TEMPLATES_UPDATE.value, + # External Links - full access + Permission.EXTERNAL_LINKS_READ.value, + Permission.EXTERNAL_LINKS_CREATE.value, + Permission.EXTERNAL_LINKS_UPDATE.value, + Permission.EXTERNAL_LINKS_DELETE.value, + # Discovery + Permission.DISCOVERY_SCAN.value, + # Firmware - read only + Permission.FIRMWARE_READ.value, + # Stats & History + Permission.AMS_HISTORY_READ.value, + Permission.STATS_READ.value, + Permission.SYSTEM_READ.value, + # Settings - read only + Permission.SETTINGS_READ.value, + # WebSocket + Permission.WEBSOCKET_CONNECT.value, + ], + "is_system": True, + }, + "Viewers": { + "description": "Read-only access to printers, archives, and queue", + "permissions": [ + # Read-only access + Permission.PRINTERS_READ.value, + Permission.ARCHIVES_READ.value, + Permission.QUEUE_READ.value, + Permission.LIBRARY_READ.value, + Permission.PROJECTS_READ.value, + Permission.FILAMENTS_READ.value, + Permission.SMART_PLUGS_READ.value, + Permission.CAMERA_VIEW.value, + Permission.MAINTENANCE_READ.value, + Permission.KPROFILES_READ.value, + Permission.NOTIFICATIONS_READ.value, + Permission.NOTIFICATION_TEMPLATES_READ.value, + Permission.EXTERNAL_LINKS_READ.value, + Permission.FIRMWARE_READ.value, + Permission.AMS_HISTORY_READ.value, + Permission.STATS_READ.value, + Permission.SYSTEM_READ.value, + Permission.SETTINGS_READ.value, + Permission.WEBSOCKET_CONNECT.value, + ], + "is_system": True, + }, +} diff --git a/backend/app/main.py b/backend/app/main.py index a60dd8c24..d4920b859 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -183,6 +183,7 @@ from backend.app.api.routes import ( filaments, firmware, github_backup, + groups, kprofiles, library, maintenance, @@ -2526,6 +2527,7 @@ 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(groups.router, prefix=app_settings.api_prefix) app.include_router(printers.router, prefix=app_settings.api_prefix) app.include_router(archives.router, prefix=app_settings.api_prefix) app.include_router(filaments.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 4b4ac176e..add4c5a08 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,6 +3,7 @@ from backend.app.models.api_key import APIKey from backend.app.models.archive import PrintArchive from backend.app.models.filament import Filament from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog +from backend.app.models.group import Group, user_groups from backend.app.models.kprofile_note import KProfileNote from backend.app.models.library import LibraryFile, LibraryFolder from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance @@ -34,6 +35,8 @@ __all__ = [ "LibraryFolder", "LibraryFile", "User", + "Group", + "user_groups", "GitHubBackupConfig", "GitHubBackupLog", ] diff --git a/backend/app/models/group.py b/backend/app/models/group.py new file mode 100644 index 000000000..f85da813e --- /dev/null +++ b/backend/app/models/group.py @@ -0,0 +1,54 @@ +"""Group model for permission-based access control.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.types import JSON + +from backend.app.core.database import Base + +if TYPE_CHECKING: + from backend.app.models.user import User + + +# Many-to-many association table between users and groups +user_groups = Table( + "user_groups", + Base.metadata, + Column("user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), + Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True), +) + + +class Group(Base): + """Group model for organizing users and assigning permissions. + + Groups contain a list of permissions that are granted to all members. + Users can belong to multiple groups, and their permissions are additive. + System groups (Administrators, Operators, Viewers) cannot be deleted. + """ + + __tablename__ = "groups" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), unique=True, index=True) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list) + is_system: Mapped[bool] = mapped_column(Boolean, default=False) + 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()) + + # Relationship to users through association table + users: Mapped[list[User]] = relationship( + "User", + secondary=user_groups, + back_populates="groups", + lazy="selectin", + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index c18958e7a..33ad90a4c 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,20 +1,99 @@ +from __future__ import annotations + from datetime import datetime +from typing import TYPE_CHECKING from sqlalchemy import DateTime, String, func -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.core.database import Base +if TYPE_CHECKING: + from backend.app.models.group import Group + class User(Base): - """User model for authentication and authorization.""" + """User model for authentication and authorization. + + Users can belong to multiple groups, and their permissions are additive + across all groups. The legacy 'role' field is kept for backward compatibility + but is_admin property now also considers group membership. + """ __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" + role: Mapped[str] = mapped_column( + String(20), default="user" + ) # "admin" or "user" (legacy, kept for backward compat) 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()) + + # Relationship to groups through association table + groups: Mapped[list[Group]] = relationship( + "Group", + secondary="user_groups", + back_populates="users", + lazy="selectin", + ) + + @property + def is_admin(self) -> bool: + """Check if user is an admin. + + Returns True if: + - User has legacy role='admin', OR + - User belongs to the Administrators group + """ + if self.role == "admin": + return True + return any(g.name == "Administrators" for g in self.groups) + + def get_permissions(self) -> set[str]: + """Get all permissions from all groups the user belongs to. + + Returns a set of permission strings. Permissions are additive across groups. + """ + permissions: set[str] = set() + for group in self.groups: + if group.permissions: + permissions.update(group.permissions) + return permissions + + def has_permission(self, permission: str) -> bool: + """Check if user has a specific permission. + + Admins have all permissions. For other users, checks if the permission + exists in any of their groups. + """ + if self.is_admin: + return True + return permission in self.get_permissions() + + def has_all_permissions(self, *permissions: str) -> bool: + """Check if user has ALL specified permissions. + + Admins have all permissions. For other users, checks if all permissions + exist in their combined group permissions. + """ + if self.is_admin: + return True + user_permissions = self.get_permissions() + return all(p in user_permissions for p in permissions) + + def has_any_permission(self, *permissions: str) -> bool: + """Check if user has ANY of the specified permissions. + + Admins have all permissions. For other users, checks if at least one + permission exists in their combined group permissions. + """ + if self.is_admin: + return True + user_permissions = self.get_permissions() + return any(p in user_permissions for p in permissions) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 2b2bc9063..02811ea7f 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -1,6 +1,16 @@ from pydantic import BaseModel +class GroupBrief(BaseModel): + """Brief group info for embedding in user responses.""" + + id: int + name: str + + class Config: + from_attributes = True + + class LoginRequest(BaseModel): username: str password: str @@ -16,6 +26,7 @@ class UserCreate(BaseModel): username: str password: str role: str = "user" + group_ids: list[int] | None = None class UserUpdate(BaseModel): @@ -23,19 +34,28 @@ class UserUpdate(BaseModel): password: str | None = None role: str | None = None is_active: bool | None = None + group_ids: list[int] | None = None class UserResponse(BaseModel): id: int username: str - role: str + role: str # Deprecated, kept for backward compatibility is_active: bool + is_admin: bool # Computed from role and group membership + groups: list[GroupBrief] = [] + permissions: list[str] = [] # All permissions from groups created_at: str class Config: from_attributes = True +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + + class SetupRequest(BaseModel): auth_enabled: bool admin_username: str | None = None diff --git a/backend/app/schemas/group.py b/backend/app/schemas/group.py new file mode 100644 index 000000000..03cc3427b --- /dev/null +++ b/backend/app/schemas/group.py @@ -0,0 +1,89 @@ +"""Pydantic schemas for Group CRUD operations.""" + +from datetime import datetime + +from pydantic import BaseModel + + +class GroupBrief(BaseModel): + """Brief group info for embedding in other responses.""" + + id: int + name: str + + class Config: + from_attributes = True + + +class GroupCreate(BaseModel): + """Schema for creating a new group.""" + + name: str + description: str | None = None + permissions: list[str] = [] + + +class GroupUpdate(BaseModel): + """Schema for updating a group.""" + + name: str | None = None + description: str | None = None + permissions: list[str] | None = None + + +class GroupResponse(BaseModel): + """Schema for group response.""" + + id: int + name: str + description: str | None + permissions: list[str] + is_system: bool + user_count: int = 0 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class GroupDetailResponse(GroupResponse): + """Schema for detailed group response including users.""" + + users: list["UserBrief"] = [] + + +class UserBrief(BaseModel): + """Brief user info for embedding in group response.""" + + id: int + username: str + is_active: bool + + class Config: + from_attributes = True + + +class PermissionInfo(BaseModel): + """Schema for permission information.""" + + value: str + label: str + + +class PermissionCategory(BaseModel): + """Schema for a category of permissions.""" + + name: str + permissions: list[PermissionInfo] + + +class PermissionsListResponse(BaseModel): + """Schema for listing all permissions by category.""" + + categories: list[PermissionCategory] + all_permissions: list[str] + + +# Update forward references +GroupDetailResponse.model_rebuild() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1b4bb262b..e56ee875b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -67,6 +67,7 @@ async def test_engine(): archive, external_link, filament, + group, kprofile_note, maintenance, notification, @@ -122,6 +123,11 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N patch("backend.app.core.auth.async_session", test_async_session), patch("backend.app.main.init_printer_connections", mock_init_printer_connections), ): + # Seed default groups for tests that need them + from backend.app.core.database import seed_default_groups + + await seed_default_groups() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: yield client diff --git a/backend/tests/integration/test_auth_api.py b/backend/tests/integration/test_auth_api.py index f6349b043..9800c323a 100644 --- a/backend/tests/integration/test_auth_api.py +++ b/backend/tests/integration/test_auth_api.py @@ -205,7 +205,18 @@ class TestUsersAPI: @pytest.mark.asyncio @pytest.mark.integration async def test_list_users_requires_auth(self, async_client: AsyncClient): - """Verify listing users requires authentication.""" + """Verify listing users requires authentication when auth is enabled.""" + # First enable auth + await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": "authreqadmin", + "admin_password": "adminpassword123", + }, + ) + + # Now try to list users without a token response = await async_client.get("/api/v1/users/") assert response.status_code == 401 @@ -360,3 +371,321 @@ class TestAuthDisableAPI: # Verify auth is now disabled status_response = await async_client.get("/api/v1/auth/status") assert status_response.json()["auth_enabled"] is False + + +class TestGroupsAPI: + """Integration tests for /api/v1/groups/ endpoints.""" + + @pytest.fixture + async def auth_token(self, async_client: AsyncClient): + """Setup auth and return admin token.""" + await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": "groupsadmin", + "admin_password": "adminpassword123", + }, + ) + + login_response = await async_client.post( + "/api/v1/auth/login", + json={"username": "groupsadmin", "password": "adminpassword123"}, + ) + return login_response.json()["access_token"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_list_groups(self, async_client: AsyncClient, auth_token: str): + """Verify listing groups returns default groups.""" + response = await async_client.get( + "/api/v1/groups/", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + + assert response.status_code == 200 + groups = response.json() + assert isinstance(groups, list) + # Should have default groups: Administrators, Operators, Viewers + group_names = [g["name"] for g in groups] + assert "Administrators" in group_names + assert "Operators" in group_names + assert "Viewers" in group_names + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_permissions(self, async_client: AsyncClient, auth_token: str): + """Verify getting available permissions.""" + response = await async_client.get( + "/api/v1/groups/permissions", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + + assert response.status_code == 200 + permissions = response.json() + assert isinstance(permissions, dict) + # Should have permission categories + assert "Printers" in permissions or len(permissions) > 0 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_group(self, async_client: AsyncClient, auth_token: str): + """Verify creating a new group.""" + response = await async_client.post( + "/api/v1/groups/", + headers={"Authorization": f"Bearer {auth_token}"}, + json={ + "name": "Custom Group", + "description": "A custom test group", + "permissions": ["printers:read", "archives:read"], + }, + ) + + assert response.status_code == 201 + group = response.json() + assert group["name"] == "Custom Group" + assert group["description"] == "A custom test group" + assert "printers:read" in group["permissions"] + assert group["is_system"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_group(self, async_client: AsyncClient, auth_token: str): + """Verify updating a group.""" + # Create a group first + create_response = await async_client.post( + "/api/v1/groups/", + headers={"Authorization": f"Bearer {auth_token}"}, + json={ + "name": "Update Test Group", + "permissions": ["printers:read"], + }, + ) + group_id = create_response.json()["id"] + + # Update the group + response = await async_client.patch( + f"/api/v1/groups/{group_id}", + headers={"Authorization": f"Bearer {auth_token}"}, + json={ + "description": "Updated description", + "permissions": ["printers:read", "printers:control"], + }, + ) + + assert response.status_code == 200 + group = response.json() + assert group["description"] == "Updated description" + assert "printers:control" in group["permissions"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_cannot_delete_system_group(self, async_client: AsyncClient, auth_token: str): + """Verify system groups cannot be deleted.""" + # Get the Administrators group + list_response = await async_client.get( + "/api/v1/groups/", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + admin_group = next(g for g in list_response.json() if g["name"] == "Administrators") + + # Try to delete it + response = await async_client.delete( + f"/api/v1/groups/{admin_group['id']}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + + assert response.status_code == 400 + assert "system group" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_delete_custom_group(self, async_client: AsyncClient, auth_token: str): + """Verify custom groups can be deleted.""" + # Create a group + create_response = await async_client.post( + "/api/v1/groups/", + headers={"Authorization": f"Bearer {auth_token}"}, + json={"name": "Delete Test Group"}, + ) + group_id = create_response.json()["id"] + + # Delete it + response = await async_client.delete( + f"/api/v1/groups/{group_id}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + + assert response.status_code == 204 + + +class TestUserGroupsAPI: + """Integration tests for user-group assignments.""" + + @pytest.fixture + async def auth_token(self, async_client: AsyncClient): + """Setup auth and return admin token.""" + await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": "usergroupadmin", + "admin_password": "adminpassword123", + }, + ) + + login_response = await async_client.post( + "/api/v1/auth/login", + json={"username": "usergroupadmin", "password": "adminpassword123"}, + ) + return login_response.json()["access_token"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_user_with_groups(self, async_client: AsyncClient, auth_token: str): + """Verify creating a user with group assignments.""" + # Get Operators group ID + groups_response = await async_client.get( + "/api/v1/groups/", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + operators_group = next(g for g in groups_response.json() if g["name"] == "Operators") + + # Create user with group + response = await async_client.post( + "/api/v1/users/", + headers={"Authorization": f"Bearer {auth_token}"}, + json={ + "username": "groupuser", + "password": "password123", + "group_ids": [operators_group["id"]], + }, + ) + + assert response.status_code == 201 + user = response.json() + assert any(g["name"] == "Operators" for g in user["groups"]) + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_add_user_to_group(self, async_client: AsyncClient, auth_token: str): + """Verify adding a user to a group.""" + # Create a user + user_response = await async_client.post( + "/api/v1/users/", + headers={"Authorization": f"Bearer {auth_token}"}, + json={"username": "addtogroup", "password": "password123"}, + ) + user_id = user_response.json()["id"] + + # Get Viewers group + groups_response = await async_client.get( + "/api/v1/groups/", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + viewers_group = next(g for g in groups_response.json() if g["name"] == "Viewers") + + # Add user to group + response = await async_client.post( + f"/api/v1/groups/{viewers_group['id']}/users/{user_id}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + + assert response.status_code == 204 + + # Verify user is in group + user_check = await async_client.get( + f"/api/v1/users/{user_id}", + headers={"Authorization": f"Bearer {auth_token}"}, + ) + assert any(g["name"] == "Viewers" for g in user_check.json()["groups"]) + + +class TestChangePasswordAPI: + """Integration tests for /api/v1/users/me/change-password endpoint.""" + + @pytest.fixture + async def user_token(self, async_client: AsyncClient): + """Setup auth and return regular user token.""" + # Enable auth with admin + await async_client.post( + "/api/v1/auth/setup", + json={ + "auth_enabled": True, + "admin_username": "pwchangeadmin", + "admin_password": "adminpassword123", + }, + ) + + admin_login = await async_client.post( + "/api/v1/auth/login", + json={"username": "pwchangeadmin", "password": "adminpassword123"}, + ) + admin_token = admin_login.json()["access_token"] + + # Create a regular user + await async_client.post( + "/api/v1/users/", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"username": "pwchangeuser", "password": "oldpassword123"}, + ) + + # Login as regular user + user_login = await async_client.post( + "/api/v1/auth/login", + json={"username": "pwchangeuser", "password": "oldpassword123"}, + ) + return user_login.json()["access_token"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_change_password_success(self, async_client: AsyncClient, user_token: str): + """Verify user can change their own password.""" + response = await async_client.post( + "/api/v1/users/me/change-password", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "current_password": "oldpassword123", + "new_password": "newpassword456", + }, + ) + + assert response.status_code == 200 + assert "success" in response.json()["message"].lower() + + # Verify can login with new password + login_response = await async_client.post( + "/api/v1/auth/login", + json={"username": "pwchangeuser", "password": "newpassword456"}, + ) + assert login_response.status_code == 200 + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_change_password_wrong_current(self, async_client: AsyncClient, user_token: str): + """Verify changing password fails with wrong current password.""" + response = await async_client.post( + "/api/v1/users/me/change-password", + headers={"Authorization": f"Bearer {user_token}"}, + json={ + "current_password": "wrongpassword", + "new_password": "newpassword456", + }, + ) + + assert response.status_code == 400 + assert "incorrect" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_change_password_requires_auth(self, async_client: AsyncClient): + """Verify changing password requires authentication.""" + response = await async_client.post( + "/api/v1/users/me/change-password", + json={ + "current_password": "oldpassword", + "new_password": "newpassword", + }, + ) + + assert response.status_code == 401 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2836d8eea..d62d353d7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -17,7 +17,6 @@ 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'; @@ -52,7 +51,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { } function AdminRoute({ children }: { children: React.ReactNode }) { - const { authEnabled, loading, user } = useAuth(); + const { authEnabled, loading, user, isAdmin } = useAuth(); if (loading) { return
Loading...
; @@ -69,7 +68,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) { } // If user is not admin, redirect to home - if (user.role !== 'admin') { + if (!isAdmin) { return ; } @@ -125,7 +124,8 @@ function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> diff --git a/frontend/src/__tests__/contexts/AuthContext.test.tsx b/frontend/src/__tests__/contexts/AuthContext.test.tsx new file mode 100644 index 000000000..b4d6f604b --- /dev/null +++ b/frontend/src/__tests__/contexts/AuthContext.test.tsx @@ -0,0 +1,169 @@ +/** + * Tests for the AuthContext permission helpers. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { BrowserRouter } from 'react-router-dom'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; +import { AuthProvider, useAuth } from '../../contexts/AuthContext'; +import { ThemeProvider } from '../../contexts/ThemeContext'; +import { ToastProvider } from '../../contexts/ToastContext'; +import type { Permission } from '../../api/client'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + + {children} + + + + + ); + }; +} + +describe('AuthContext', () => { + describe('when auth is disabled', () => { + beforeEach(() => { + server.use( + http.get('/api/v1/auth/status', () => { + return HttpResponse.json({ + auth_enabled: false, + requires_setup: false, + }); + }) + ); + }); + + it('authEnabled is false', async () => { + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authEnabled).toBe(false); + }); + }); + + it('hasPermission returns true for any permission when auth disabled', async () => { + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authEnabled).toBe(false); + }); + + // When auth is disabled, all permissions should be granted + expect(result.current.hasPermission('printers:read' as Permission)).toBe(true); + expect(result.current.hasPermission('settings:update' as Permission)).toBe(true); + expect(result.current.hasPermission('users:delete' as Permission)).toBe(true); + }); + + it('hasAnyPermission returns true for any permissions when auth disabled', async () => { + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authEnabled).toBe(false); + }); + + expect( + result.current.hasAnyPermission('printers:read' as Permission, 'settings:update' as Permission) + ).toBe(true); + }); + + it('hasAllPermissions returns true for any permissions when auth disabled', async () => { + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authEnabled).toBe(false); + }); + + expect( + result.current.hasAllPermissions('printers:read' as Permission, 'settings:update' as Permission) + ).toBe(true); + }); + }); + + describe('when auth requires setup', () => { + beforeEach(() => { + server.use( + http.get('/api/v1/auth/status', () => { + return HttpResponse.json({ + auth_enabled: false, + requires_setup: true, + }); + }) + ); + }); + + it('requiresSetup is true', async () => { + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.requiresSetup).toBe(true); + }); + }); + }); + + describe('when auth is enabled but not logged in', () => { + beforeEach(() => { + // Clear any stored token + localStorage.removeItem('auth_token'); + + server.use( + http.get('/api/v1/auth/status', () => { + return HttpResponse.json({ + auth_enabled: true, + requires_setup: false, + }); + }) + ); + }); + + it('user is null when not logged in', async () => { + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authEnabled).toBe(true); + }); + + // User should be null when not logged in + expect(result.current.user).toBeNull(); + }); + + it('hasPermission returns false when not logged in', async () => { + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authEnabled).toBe(true); + }); + + // Without a user, permissions should be denied + expect(result.current.hasPermission('printers:read' as Permission)).toBe(false); + }); + }); +}); diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 46089ea09..ac6ac79ea 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -280,6 +280,90 @@ export const handlers = [ }); }), + http.get('/api/v1/auth/me', () => { + return HttpResponse.json({ + id: 1, + username: 'admin', + role: 'admin', + is_active: true, + is_admin: true, + groups: [{ id: 1, name: 'Administrators' }], + permissions: [], + created_at: '2024-01-01T00:00:00Z', + }); + }), + + // ======================================================================== + // Groups + // ======================================================================== + + http.get('/api/v1/groups/', () => { + return HttpResponse.json([ + { + id: 1, + name: 'Administrators', + description: 'Full access to all features', + permissions: ['printers:read', 'settings:update', 'users:create'], + is_system: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 2, + name: 'Operators', + description: 'Control printers and manage content', + permissions: ['printers:read', 'printers:control'], + is_system: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 3, + name: 'Viewers', + description: 'Read-only access', + permissions: ['printers:read'], + is_system: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + ]); + }), + + http.get('/api/v1/groups/permissions', () => { + return HttpResponse.json({ + 'Printers': ['printers:read', 'printers:create', 'printers:update', 'printers:delete', 'printers:control'], + 'Archives': ['archives:read', 'archives:create', 'archives:update', 'archives:delete'], + 'Settings': ['settings:read', 'settings:update'], + }); + }), + + http.post('/api/v1/groups/', async ({ request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ + id: 4, + ...body, + is_system: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }); + }), + + http.patch('/api/v1/groups/:id', async ({ params, request }) => { + const body = (await request.json()) as Record; + return HttpResponse.json({ + id: Number(params.id), + name: 'Updated Group', + ...body, + is_system: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }); + }), + + http.delete('/api/v1/groups/:id', () => { + return new HttpResponse(null, { status: 204 }); + }), + // ======================================================================== // Version / Health // ======================================================================== diff --git a/frontend/src/__tests__/pages/GroupsPage.test.tsx b/frontend/src/__tests__/pages/GroupsPage.test.tsx new file mode 100644 index 000000000..78be18961 --- /dev/null +++ b/frontend/src/__tests__/pages/GroupsPage.test.tsx @@ -0,0 +1,136 @@ +/** + * Tests for the GroupsPage component. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { waitFor } from '@testing-library/react'; +import { render } from '../utils'; +import { GroupsPage } from '../../pages/GroupsPage'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/server'; + +const mockGroups = [ + { + id: 1, + name: 'Administrators', + description: 'Full access to all features', + permissions: ['printers:read', 'printers:control', 'settings:read', 'settings:update', 'users:read', 'users:create'], + is_system: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 2, + name: 'Operators', + description: 'Control printers and manage content', + permissions: ['printers:read', 'printers:control', 'archives:read', 'queue:read', 'queue:create'], + is_system: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 3, + name: 'Viewers', + description: 'Read-only access', + permissions: ['printers:read', 'archives:read', 'queue:read'], + is_system: true, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }, +]; + +const mockPermissions = { + 'Printers': ['printers:read', 'printers:create', 'printers:update', 'printers:delete', 'printers:control'], + 'Archives': ['archives:read', 'archives:create', 'archives:update', 'archives:delete'], + 'Queue': ['queue:read', 'queue:create', 'queue:update', 'queue:delete'], + 'Settings': ['settings:read', 'settings:update'], + 'Users': ['users:read', 'users:create', 'users:update', 'users:delete'], +}; + +describe('GroupsPage', () => { + beforeEach(() => { + server.use( + http.get('/api/v1/groups/', () => { + return HttpResponse.json(mockGroups); + }), + http.get('/api/v1/groups/permissions', () => { + return HttpResponse.json(mockPermissions); + }), + http.get('/api/v1/auth/status', () => { + return HttpResponse.json({ + auth_enabled: false, + requires_setup: false, + }); + }), + http.get('/api/v1/users/', () => { + return HttpResponse.json([]); + }) + ); + }); + + describe('rendering', () => { + it('renders the page', async () => { + render(); + + // Page should render without errors + await waitFor(() => { + expect(document.body).toBeInTheDocument(); + }); + }); + + it('renders group names from API', async () => { + render(); + + await waitFor(() => { + // Check that the groups are rendered + expect(document.body.textContent).toContain('Administrators'); + expect(document.body.textContent).toContain('Operators'); + expect(document.body.textContent).toContain('Viewers'); + }); + }); + + it('shows group descriptions', async () => { + render(); + + await waitFor(() => { + expect(document.body.textContent).toContain('Full access to all features'); + }); + }); + }); + + describe('API integration', () => { + it('fetches groups on mount', async () => { + let groupsFetched = false; + + server.use( + http.get('/api/v1/groups/', () => { + groupsFetched = true; + return HttpResponse.json(mockGroups); + }) + ); + + render(); + + await waitFor(() => { + expect(groupsFetched).toBe(true); + }); + }); + + it('fetches permissions on mount', async () => { + let permissionsFetched = false; + + server.use( + http.get('/api/v1/groups/permissions', () => { + permissionsFetched = true; + return HttpResponse.json(mockPermissions); + }) + ); + + render(); + + await waitFor(() => { + expect(permissionsFetched).toBe(true); + }); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/LoginPage.test.tsx b/frontend/src/__tests__/pages/LoginPage.test.tsx index 3e119101a..d6fb42e81 100644 --- a/frontend/src/__tests__/pages/LoginPage.test.tsx +++ b/frontend/src/__tests__/pages/LoginPage.test.tsx @@ -119,11 +119,13 @@ describe('LoginPage', () => { it('shows loading state during login', async () => { const user = userEvent.setup(); + let resolveLogin: () => void; + const loginPromise = new Promise(resolve => { resolveLogin = resolve; }); - // Slow login endpoint + // Slow login endpoint that we control server.use( http.post('/api/v1/auth/login', async () => { - await new Promise(resolve => setTimeout(resolve, 100)); + await loginPromise; return HttpResponse.json({ access_token: 'test-token', token_type: 'bearer', @@ -148,10 +150,13 @@ describe('LoginPage', () => { await user.type(screen.getByLabelText(/Password/i), 'testpass'); await user.click(screen.getByRole('button', { name: /Sign in/i })); - // Check for loading state + // Check for loading state - button text should change to "Logging in..." await waitFor(() => { - expect(screen.getByRole('button')).toBeDisabled(); + expect(screen.getByRole('button', { name: /Logging in/i })).toBeInTheDocument(); }); + + // Release the login request + resolveLogin!(); }); }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c29d1d1e9..5ad78680d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -50,6 +50,12 @@ async function request( throw new Error(message); } + // Handle empty responses (204 No Content, etc.) + const contentLength = response.headers.get('content-length'); + if (response.status === 204 || contentLength === '0') { + return undefined as T; + } + return await response.json(); } @@ -1713,6 +1719,82 @@ export interface ExternalLinkUpdate { icon?: string; } +// Permission type - all available permissions +export type Permission = + | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' + | 'archives:read' | 'archives:create' | 'archives:update' | 'archives:delete' | 'archives:reprint' + | 'queue:read' | 'queue:create' | 'queue:update' | 'queue:delete' | 'queue:reorder' + | 'library:read' | 'library:upload' | 'library:update' | 'library:delete' + | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete' + | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete' + | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control' + | 'camera:view' + | 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete' + | 'kprofiles:read' | 'kprofiles:create' | 'kprofiles:update' | 'kprofiles:delete' + | 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete' + | 'notification_templates:read' | 'notification_templates:update' + | 'external_links:read' | 'external_links:create' | 'external_links:update' | 'external_links:delete' + | 'discovery:scan' + | 'firmware:read' | 'firmware:update' + | 'ams_history:read' + | 'stats:read' + | 'system:read' + | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore' + | 'github:backup' | 'github:restore' + | 'cloud:auth' + | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete' + | 'users:read' | 'users:create' | 'users:update' | 'users:delete' + | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete' + | 'websocket:connect'; + +// Group types +export interface GroupBrief { + id: number; + name: string; +} + +export interface Group { + id: number; + name: string; + description: string | null; + permissions: Permission[]; + is_system: boolean; + user_count: number; + created_at: string; + updated_at: string; +} + +export interface GroupDetail extends Group { + users: Array<{ id: number; username: string; is_active: boolean }>; +} + +export interface GroupCreate { + name: string; + description?: string; + permissions: Permission[]; +} + +export interface GroupUpdate { + name?: string; + description?: string; + permissions?: Permission[]; +} + +export interface PermissionInfo { + value: Permission; + label: string; +} + +export interface PermissionCategory { + name: string; + permissions: PermissionInfo[]; +} + +export interface PermissionsListResponse { + categories: PermissionCategory[]; + all_permissions: Permission[]; +} + // Auth types export interface LoginRequest { username: string; @@ -1728,8 +1810,11 @@ export interface LoginResponse { export interface UserResponse { id: number; username: string; - role: string; + role: string; // Deprecated, kept for backward compatibility is_active: boolean; + is_admin: boolean; // Computed from role and group membership + groups: GroupBrief[]; + permissions: Permission[]; // All permissions from groups created_at: string; } @@ -1737,6 +1822,7 @@ export interface UserCreate { username: string; password: string; role: string; + group_ids?: number[]; } export interface UserUpdate { @@ -1744,6 +1830,7 @@ export interface UserUpdate { password?: string; role?: string; is_active?: boolean; + group_ids?: number[]; } export interface SetupRequest { @@ -1786,7 +1873,7 @@ export const api = { method: 'POST', }), - // Users (admin only) + // Users getUsers: () => request('/users/'), getUser: (id: number) => request(`/users/${id}`), createUser: (data: UserCreate) => @@ -1803,6 +1890,38 @@ export const api = { request(`/users/${id}`, { method: 'DELETE', }), + changePassword: (currentPassword: string, newPassword: string) => + request<{ message: string }>('/users/me/change-password', { + method: 'POST', + body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), + }), + + // Groups + getPermissions: () => request('/groups/permissions'), + getGroups: () => request('/groups/'), + getGroup: (id: number) => request(`/groups/${id}`), + createGroup: (data: GroupCreate) => + request('/groups/', { + method: 'POST', + body: JSON.stringify(data), + }), + updateGroup: (id: number, data: GroupUpdate) => + request(`/groups/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deleteGroup: (id: number) => + request(`/groups/${id}`, { + method: 'DELETE', + }), + addUserToGroup: (groupId: number, userId: number) => + request(`/groups/${groupId}/users/${userId}`, { + method: 'POST', + }), + removeUserFromGroup: (groupId: number, userId: number) => + request(`/groups/${groupId}/users/${userId}`, { + method: 'DELETE', + }), // Printers getPrinters: () => request('/printers/'), diff --git a/frontend/src/components/ContextMenu.tsx b/frontend/src/components/ContextMenu.tsx index 7ad3efb46..60d527086 100644 --- a/frontend/src/components/ContextMenu.tsx +++ b/frontend/src/components/ContextMenu.tsx @@ -9,6 +9,7 @@ export interface ContextMenuItem { disabled?: boolean; divider?: boolean; submenu?: ContextMenuItem[]; + title?: string; } interface ContextMenuProps { @@ -163,6 +164,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { } }} disabled={item.disabled} + title={item.title} className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${ item.disabled ? 'text-bambu-gray cursor-not-allowed' diff --git a/frontend/src/components/KProfilesView.tsx b/frontend/src/components/KProfilesView.tsx index 71b253556..68efbcd40 100644 --- a/frontend/src/components/KProfilesView.tsx +++ b/frontend/src/components/KProfilesView.tsx @@ -19,10 +19,11 @@ import { StickyNote, } from 'lucide-react'; import { api } from '../api/client'; -import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client'; +import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client'; import { Card, CardContent } from './Card'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; +import { useAuth } from '../contexts/AuthContext'; interface KProfileCardProps { profile: KProfile; @@ -154,6 +155,7 @@ interface KProfileModalProps { onClose: () => void; onSave: () => void; onSaveNote?: (settingId: string, note: string) => void; // Callback to save note + hasPermission: (permission: Permission) => boolean; } function KProfileModal({ @@ -167,6 +169,7 @@ function KProfileModal({ onClose, onSave, onSaveNote, + hasPermission, }: KProfileModalProps) { const { showToast } = useToast(); @@ -590,7 +593,8 @@ function KProfileModal({ type="button" variant="secondary" onClick={() => setShowDeleteConfirm(true)} - disabled={deleteMutation.isPending || isSyncing} + disabled={deleteMutation.isPending || isSyncing || !hasPermission('kprofiles:delete')} + title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete K-profiles' : undefined} className="text-red-500 hover:bg-red-500/10" > {deleteMutation.isPending ? ( @@ -611,7 +615,8 @@ function KProfileModal({ - @@ -1205,8 +1216,8 @@ export function KProfilesView() { {authEnabled && user && ( - + <> + + + )} {/* Bottom row: version */} @@ -650,17 +675,26 @@ export function Layout() { )} )} - - `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${ - isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white' - }` - } - title={t('nav.system')} - > - - + {hasPermission('system:read') ? ( + + `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${ + isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white' + }` + } + title={t('nav.system')} + > + + + ) : ( + + + + )} : } {authEnabled && user && ( - + <> + + + )} )} @@ -800,6 +843,141 @@ export function Layout() { )} + + {/* Change Password Modal */} + {showChangePasswordModal && ( +
{ + setShowChangePasswordModal(false); + setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' }); + }} + > + e.stopPropagation()} + > + +
+
+ +

Change Password

+
+ +
+
+ +
+
+ + setChangePasswordData({ ...changePasswordData, currentPassword: 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 current password" + autoComplete="current-password" + /> +
+
+ + setChangePasswordData({ ...changePasswordData, newPassword: 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 new password (min 6 characters)" + autoComplete="new-password" + minLength={6} + /> +
+
+ + setChangePasswordData({ ...changePasswordData, confirmPassword: e.target.value })} + className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${ + changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword + ? 'border-red-500' + : 'border-bambu-dark-tertiary' + }`} + placeholder="Confirm new password" + autoComplete="new-password" + minLength={6} + /> + {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && ( +

Passwords do not match

+ )} +
+
+
+ + +
+
+
+
+ )} ); } diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 166b4c44c..4fb9fb2f6 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -1,16 +1,20 @@ -import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { api, getAuthToken, setAuthToken } from '../api/client'; -import type { UserResponse } from '../api/client'; +import type { Permission, UserResponse } from '../api/client'; interface AuthContextType { user: UserResponse | null; authEnabled: boolean; requiresSetup: boolean; loading: boolean; + isAdmin: boolean; login: (username: string, password: string) => Promise; logout: () => void; refreshUser: () => Promise; refreshAuth: () => Promise; + hasPermission: (permission: Permission) => boolean; + hasAnyPermission: (...permissions: Permission[]) => boolean; + hasAllPermissions: (...permissions: Permission[]) => boolean; } const AuthContext = createContext(undefined); @@ -122,6 +126,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await checkAuthStatus(); }; + // Memoize permission set for efficient lookups + const permissionSet = useMemo(() => { + return new Set(user?.permissions ?? []); + }, [user?.permissions]); + + // Computed admin status + const isAdmin = useMemo(() => { + if (!authEnabled) return true; // Auth disabled = admin access + return user?.is_admin ?? false; + }, [authEnabled, user?.is_admin]); + + // Permission check functions + const hasPermission = useCallback((permission: Permission): boolean => { + if (!authEnabled) return true; // Auth disabled = allow all + if (isAdmin) return true; // Admins have all permissions + return permissionSet.has(permission); + }, [authEnabled, isAdmin, permissionSet]); + + const hasAnyPermission = useCallback((...permissions: Permission[]): boolean => { + if (!authEnabled) return true; + if (isAdmin) return true; + return permissions.some(p => permissionSet.has(p)); + }, [authEnabled, isAdmin, permissionSet]); + + const hasAllPermissions = useCallback((...permissions: Permission[]): boolean => { + if (!authEnabled) return true; + if (isAdmin) return true; + return permissions.every(p => permissionSet.has(p)); + }, [authEnabled, isAdmin, permissionSet]); + return ( {children} diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index 0aef08234..a820943ba 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -69,6 +69,7 @@ import { CompareArchivesModal } from '../components/CompareArchivesModal'; import { PendingUploadsPanel } from '../components/PendingUploadsPanel'; import { TagManagementModal } from '../components/TagManagementModal'; import { useToast } from '../contexts/ToastContext'; +import { useAuth } from '../contexts/AuthContext'; function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -122,6 +123,7 @@ function ArchiveCard({ const queryClient = useQueryClient(); const { showToast } = useToast(); + const { hasPermission } = useAuth(); const isMobile = useIsMobile(); const [showViewer, setShowViewer] = useState(false); const [showReprint, setShowReprint] = useState(false); @@ -284,11 +286,15 @@ function ArchiveCard({ label: 'Print', icon: , onClick: () => setShowReprint(true), + disabled: !hasPermission('archives:reprint'), + title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined, }, { label: 'Schedule', icon: , onClick: () => setShowSchedule(true), + disabled: !hasPermission('queue:create'), + title: !hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined, }, { label: 'Open in Bambu Studio', @@ -335,7 +341,8 @@ function ArchiveCard({ label: 'Scan for Timelapse', icon: , onClick: () => timelapseScanMutation.mutate(), - disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending, + disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }, { label: '', divider: true, onClick: () => {} }, { @@ -351,22 +358,30 @@ function ArchiveCard({ source3mfInputRef.current?.click(); } }, + disabled: !archive.source_3mf_path && !hasPermission('archives:update'), + title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined, }, ...(archive.source_3mf_path ? [{ label: 'Replace Source 3MF', icon: , onClick: () => source3mfInputRef.current?.click(), + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }, { label: 'Remove Source 3MF', icon: , onClick: () => setShowDeleteSource3mfConfirm(true), danger: true, + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }] : []), { label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D', icon: , onClick: () => f3dInputRef.current?.click(), + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }, ...(archive.f3d_path ? [{ label: 'Download F3D', @@ -383,6 +398,8 @@ function ArchiveCard({ icon: , onClick: () => setShowDeleteF3dConfirm(true), danger: true, + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }] : []), { label: '', divider: true, onClick: () => {} }, { @@ -394,6 +411,8 @@ function ArchiveCard({ link.download = `${archive.print_name || archive.filename}.3mf`; link.click(); }, + disabled: !hasPermission('archives:read'), + title: !hasPermission('archives:read') ? 'You do not have permission to download archives' : undefined, }, { label: 'Copy Download Link', @@ -406,6 +425,8 @@ function ArchiveCard({ showToast('Failed to copy link', 'error'); }); }, + disabled: !hasPermission('archives:read'), + title: !hasPermission('archives:read') ? 'You do not have permission to copy download links' : undefined, }, { label: 'QR Code', @@ -428,11 +449,15 @@ function ArchiveCard({ label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites', icon: , onClick: () => favoriteMutation.mutate(), + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }, { label: 'Edit', icon: , onClick: () => setShowEdit(true), + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }, ...(archive.project_id && archive.project_name ? [{ label: `Go to Project: ${archive.project_name}`, @@ -443,6 +468,8 @@ function ArchiveCard({ label: 'Add to Project', icon: , onClick: () => {}, + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, submenu: (() => { const items: ContextMenuItem[] = []; @@ -452,6 +479,7 @@ function ArchiveCard({ label: 'Remove from Project', icon: , onClick: () => assignProjectMutation.mutate(null), + disabled: !hasPermission('archives:update'), }); } @@ -478,7 +506,7 @@ function ArchiveCard({ label: p.name, icon:
, onClick: () => assignProjectMutation.mutate(p.id), - disabled: archive.project_id === p.id, + disabled: archive.project_id === p.id || !hasPermission('archives:update'), }); }); } @@ -498,6 +526,8 @@ function ArchiveCard({ icon: , onClick: () => setShowDeleteConfirm(true), danger: true, + disabled: !hasPermission('archives:delete'), + title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined, }, ]; @@ -617,15 +647,22 @@ function ArchiveCard({ {/* Favorite star */} {(archive.status === 'failed' || archive.status === 'aborted') && ( @@ -865,6 +902,8 @@ function ArchiveCard({ size="sm" className="flex-1 min-w-0" onClick={() => setShowReprint(true)} + disabled={!hasPermission('archives:reprint')} + title={!hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined} > Reprint @@ -947,7 +986,8 @@ function ArchiveCard({ size="sm" className="min-w-0 p-1 sm:p-1.5" onClick={() => setShowEdit(true)} - title="Edit" + disabled={!hasPermission('archives:update')} + title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'} > @@ -956,7 +996,8 @@ function ArchiveCard({ size="sm" className="min-w-0 p-1 sm:p-1.5" onClick={() => setShowDeleteConfirm(true)} - title="Delete" + disabled={!hasPermission('archives:delete')} + title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'} > @@ -1213,6 +1254,7 @@ function ArchiveListRow({ }) { const queryClient = useQueryClient(); const { showToast } = useToast(); + const { hasPermission } = useAuth(); const [showEdit, setShowEdit] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showReprint, setShowReprint] = useState(false); @@ -1357,11 +1399,15 @@ function ArchiveListRow({ label: 'Print', icon: , onClick: () => setShowReprint(true), + disabled: !hasPermission('archives:reprint'), + title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined, }, { label: 'Schedule', icon: , onClick: () => setShowSchedule(true), + disabled: !hasPermission('queue:create'), + title: !hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined, }, { label: 'Open in Bambu Studio', @@ -1408,7 +1454,8 @@ function ArchiveListRow({ label: 'Scan for Timelapse', icon: , onClick: () => timelapseScanMutation.mutate(), - disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending, + disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }, { label: '', divider: true, onClick: () => {} }, { @@ -1424,22 +1471,30 @@ function ArchiveListRow({ source3mfInputRef.current?.click(); } }, + disabled: !archive.source_3mf_path && !hasPermission('archives:update'), + title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined, }, ...(archive.source_3mf_path ? [{ label: 'Replace Source 3MF', icon: , onClick: () => source3mfInputRef.current?.click(), + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }, { label: 'Remove Source 3MF', icon: , onClick: () => setShowDeleteSource3mfConfirm(true), danger: true, + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }] : []), { label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D', icon: , onClick: () => f3dInputRef.current?.click(), + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }, ...(archive.f3d_path ? [{ label: 'Download F3D', @@ -1456,6 +1511,8 @@ function ArchiveListRow({ icon: , onClick: () => setShowDeleteF3dConfirm(true), danger: true, + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }] : []), { label: '', divider: true, onClick: () => {} }, { @@ -1467,6 +1524,8 @@ function ArchiveListRow({ link.download = `${archive.print_name || archive.filename}.3mf`; link.click(); }, + disabled: !hasPermission('archives:read'), + title: !hasPermission('archives:read') ? 'You do not have permission to download archives' : undefined, }, { label: 'Copy Download Link', @@ -1479,6 +1538,8 @@ function ArchiveListRow({ showToast('Failed to copy link', 'error'); }); }, + disabled: !hasPermission('archives:read'), + title: !hasPermission('archives:read') ? 'You do not have permission to copy download links' : undefined, }, { label: 'QR Code', @@ -1501,11 +1562,15 @@ function ArchiveListRow({ label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites', icon: , onClick: () => favoriteMutation.mutate(), + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }, { label: 'Edit', icon: , onClick: () => setShowEdit(true), + disabled: !hasPermission('archives:update'), + title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, }, ...(archive.project_id && archive.project_name ? [{ label: `Go to Project: ${archive.project_name}`, @@ -1566,6 +1631,8 @@ function ArchiveListRow({ icon: , onClick: () => setShowDeleteConfirm(true), danger: true, + disabled: !hasPermission('archives:delete'), + title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined, }, ]; @@ -1698,7 +1765,8 @@ function ArchiveListRow({ variant="ghost" size="sm" onClick={() => setShowEdit(true)} - title="Edit" + disabled={!hasPermission('archives:update')} + title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'} > @@ -1706,7 +1774,8 @@ function ArchiveListRow({ variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} - title="Delete" + disabled={!hasPermission('archives:delete')} + title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'} > @@ -1960,6 +2029,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] = export function ArchivesPage() { const queryClient = useQueryClient(); const { showToast } = useToast(); + const { hasPermission } = useAuth(); const searchInputRef = useRef(null); const [search, setSearch] = useState(''); const [filterPrinter, setFilterPrinter] = useState(() => { @@ -2375,6 +2445,8 @@ export function ArchivesPage() { variant="secondary" size="sm" onClick={() => setShowBatchTag(true)} + disabled={!hasPermission('archives:update')} + title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined} > Tags @@ -2383,6 +2455,8 @@ export function ArchivesPage() { variant="secondary" size="sm" onClick={() => setShowBatchProject(true)} + disabled={!hasPermission('archives:update')} + title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined} > Project @@ -2390,6 +2464,8 @@ export function ArchivesPage() { )} - diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 944e85cd5..9f2b5e463 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -45,12 +45,14 @@ import type { LibraryFolderUpdate, AppSettings, Archive, + Permission, } from '../api/client'; import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { PrintModal } from '../components/PrintModal'; import { useToast } from '../contexts/ToastContext'; import { useIsMobile } from '../hooks/useIsMobile'; +import { useAuth } from '../contexts/AuthContext'; type SortField = 'name' | 'date' | 'size' | 'type' | 'prints'; type SortDirection = 'asc' | 'desc'; @@ -724,9 +726,10 @@ interface FolderTreeItemProps { onRename: (folder: LibraryFolderTree) => void; depth?: number; wrapNames?: boolean; + hasPermission: (permission: Permission) => boolean; } -function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false }: FolderTreeItemProps) { +function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, hasPermission }: FolderTreeItemProps) { const [expanded, setExpanded] = useState(true); const [showActions, setShowActions] = useState(false); const hasChildren = folder.children.length > 0; @@ -799,22 +802,34 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
setShowActions(false)} />
@@ -865,9 +881,10 @@ interface FileCardProps { onRename?: (file: LibraryFileListItem) => void; onGenerateThumbnail?: (file: LibraryFileListItem) => void; thumbnailVersion?: number; + hasPermission: (permission: Permission) => boolean; } -function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) { +function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission }: FileCardProps) { const [showActions, setShowActions] = useState(false); return ( @@ -936,8 +953,12 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
{onPrint && isSlicedFilename(file.filename) && ( )} {onRename && ( )} - - @@ -1634,6 +1685,7 @@ export function FileManagerPage() { onLink={setLinkFolder} onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })} wrapNames={wrapFolderNames} + hasPermission={hasPermission} /> ))}
@@ -1752,6 +1804,8 @@ export function FileManagerPage() { variant="primary" size="sm" onClick={() => setPrintMultiFile(selectedSlicedFiles[0])} + disabled={!hasPermission('printers:control')} + title={!hasPermission('printers:control') ? 'You do not have permission to print' : undefined} > Print @@ -1762,7 +1816,8 @@ export function FileManagerPage() { variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'} size="sm" onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))} - disabled={addToQueueMutation.isPending} + disabled={addToQueueMutation.isPending || !hasPermission('queue:create')} + title={!hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined} > {addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`} @@ -1772,6 +1827,8 @@ export function FileManagerPage() { variant="secondary" size="sm" onClick={() => setShowMoveModal(true)} + disabled={!hasPermission('library:update')} + title={!hasPermission('library:update') ? 'You do not have permission to move files' : undefined} > Move @@ -1786,6 +1843,8 @@ export function FileManagerPage() { setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length }); } }} + disabled={!hasPermission('library:delete')} + title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined} > Delete @@ -1825,7 +1884,11 @@ export function FileManagerPage() { ? 'Upload files or move files into this folder to get started.' : 'Upload files to start organizing your print-related files.'}

- @@ -1860,6 +1923,7 @@ export function FileManagerPage() { onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })} onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)} thumbnailVersion={thumbnailVersions[file.id]} + hasPermission={hasPermission} /> ))}
@@ -1946,50 +2010,78 @@ export function FileManagerPage() { {isSlicedFilename(file.filename) && ( <> )} {file.file_type === 'stl' && ( )} diff --git a/frontend/src/pages/GroupsPage.tsx b/frontend/src/pages/GroupsPage.tsx new file mode 100644 index 000000000..996d25719 --- /dev/null +++ b/frontend/src/pages/GroupsPage.tsx @@ -0,0 +1,496 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + X, + Plus, + Edit2, + Trash2, + Save, + Loader2, + Shield, + ArrowLeft, + Users, + Check, + ChevronDown, + ChevronRight, +} from 'lucide-react'; +import { api } from '../api/client'; +import type { Group, GroupCreate, GroupUpdate, Permission, PermissionCategory } 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 GroupsPage() { + const navigate = useNavigate(); + const { hasPermission } = useAuth(); + const { showToast } = useToast(); + const queryClient = useQueryClient(); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + const [deleteGroupId, setDeleteGroupId] = useState(null); + const [formData, setFormData] = useState<{ + name: string; + description: string; + permissions: Permission[]; + }>({ + name: '', + description: '', + permissions: [], + }); + const [expandedCategories, setExpandedCategories] = useState>(new Set()); + + // Close modal on Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && (showCreateModal || editingGroup)) { + setShowCreateModal(false); + setEditingGroup(null); + resetForm(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [showCreateModal, editingGroup]); + + const { data: groups = [], isLoading: groupsLoading } = useQuery({ + queryKey: ['groups'], + queryFn: () => api.getGroups(), + enabled: hasPermission('groups:read'), + }); + + const { data: permissionsData } = useQuery({ + queryKey: ['permissions'], + queryFn: () => api.getPermissions(), + enabled: hasPermission('groups:read'), + }); + + const createMutation = useMutation({ + mutationFn: (data: GroupCreate) => api.createGroup(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groups'] }); + setShowCreateModal(false); + resetForm(); + showToast('Group created successfully'); + }, + onError: (error: Error) => { + showToast(error.message, 'error'); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: number; data: GroupUpdate }) => api.updateGroup(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groups'] }); + setEditingGroup(null); + resetForm(); + showToast('Group updated successfully'); + }, + onError: (error: Error) => { + showToast(error.message, 'error'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (id: number) => api.deleteGroup(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['groups'] }); + showToast('Group deleted successfully'); + }, + onError: (error: Error) => { + showToast(error.message, 'error'); + }, + }); + + const resetForm = () => { + setFormData({ name: '', description: '', permissions: [] }); + setExpandedCategories(new Set()); + }; + + const handleCreate = () => { + if (!formData.name.trim()) { + showToast('Please enter a group name', 'error'); + return; + } + createMutation.mutate({ + name: formData.name, + description: formData.description || undefined, + permissions: formData.permissions, + }); + }; + + const handleUpdate = () => { + if (!editingGroup) return; + if (!formData.name.trim()) { + showToast('Please enter a group name', 'error'); + return; + } + updateMutation.mutate({ + id: editingGroup.id, + data: { + name: formData.name !== editingGroup.name ? formData.name : undefined, + description: formData.description, + permissions: formData.permissions, + }, + }); + }; + + const handleDelete = (id: number) => { + setDeleteGroupId(id); + }; + + const startEdit = (group: Group) => { + setEditingGroup(group); + setFormData({ + name: group.name, + description: group.description || '', + permissions: group.permissions, + }); + // Expand categories that have selected permissions + const cats = new Set(); + permissionsData?.categories.forEach((cat) => { + if (cat.permissions.some((p) => group.permissions.includes(p.value))) { + cats.add(cat.name); + } + }); + setExpandedCategories(cats); + }; + + const toggleCategory = (categoryName: string) => { + setExpandedCategories((prev) => { + const next = new Set(prev); + if (next.has(categoryName)) { + next.delete(categoryName); + } else { + next.add(categoryName); + } + return next; + }); + }; + + const togglePermission = (permission: Permission) => { + setFormData((prev) => { + const permissions = prev.permissions.includes(permission) + ? prev.permissions.filter((p) => p !== permission) + : [...prev.permissions, permission]; + return { ...prev, permissions }; + }); + }; + + const toggleCategoryPermissions = (category: PermissionCategory, checked: boolean) => { + setFormData((prev) => { + const categoryPerms = category.permissions.map((p) => p.value); + const otherPerms = prev.permissions.filter((p) => !categoryPerms.includes(p)); + const permissions = checked ? [...otherPerms, ...categoryPerms] : otherPerms; + return { ...prev, permissions }; + }); + }; + + const isCategoryFullySelected = (category: PermissionCategory) => { + return category.permissions.every((p) => formData.permissions.includes(p.value)); + }; + + const isCategoryPartiallySelected = (category: PermissionCategory) => { + const selected = category.permissions.filter((p) => formData.permissions.includes(p.value)); + return selected.length > 0 && selected.length < category.permissions.length; + }; + + // Permission check + if (!hasPermission('groups:read')) { + return ( +
+ + +
+ +

You do not have permission to access this page.

+
+
+
+
+ ); + } + + const renderPermissionEditor = () => ( +
+ {permissionsData?.categories.map((category) => ( +
+
toggleCategory(category.name)} + > +
+ + {category.name} + + ({category.permissions.filter((p) => formData.permissions.includes(p.value)).length}/ + {category.permissions.length}) + +
+ {expandedCategories.has(category.name) ? ( + + ) : ( + + )} +
+ {expandedCategories.has(category.name) && ( +
+ {category.permissions.map((perm) => ( + + ))} +
+ )} +
+ ))} +
+ ); + + return ( +
+
+
+ +
+

+ + Group Management +

+

+ Manage permission groups for access control +

+
+
+ {hasPermission('groups:create') && ( + + )} +
+ + {groupsLoading ? ( +
+ +
+ ) : ( +
+ {groups.map((group) => ( + + +
+
+ +

{group.name}

+ {group.is_system && ( + + System + + )} +
+
+
+ +

{group.description || 'No description'}

+
+
+ + {group.user_count} users +
+
+ {group.permissions.length} permissions +
+
+
+ {hasPermission('groups:update') && ( + + )} + {hasPermission('groups:delete') && !group.is_system && ( + + )} +
+
+
+ ))} +
+ )} + + {/* Create/Edit Group Modal */} + {(showCreateModal || editingGroup) && ( +
{ + setShowCreateModal(false); + setEditingGroup(null); + resetForm(); + }} + > + e.stopPropagation()} + > + +
+
+ +

+ {editingGroup ? 'Edit Group' : 'Create Group'} +

+
+ +
+
+ +
+
+ + setFormData({ ...formData, name: e.target.value })} + disabled={editingGroup?.is_system} + 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 disabled:opacity-50" + placeholder="Enter group name" + /> + {editingGroup?.is_system && ( +

System group names cannot be changed

+ )} +
+
+ +