Explorar el Código

Add group-based permissions system with granular access control

Implement a full permissions system replacing simple admin/user roles:

Backend:
- Add Group model with many-to-many user relationship
- Add 50+ granular permissions (resource:action pattern)
- Create default groups: Administrators, Operators, Viewers
- Add permission-checking dependencies for route protection
- Add groups API endpoints (CRUD, user assignment)
- Add change password endpoint for users
- Update backup/restore to include groups
- Migrate existing users to groups on startup

Frontend:
- Add GroupsPage for managing groups and permissions
- Add permission helpers to AuthContext (hasPermission, hasAnyPermission)
- Add PermissionRoute component for protected routes
- Disable buttons/features based on permissions (with tooltips)
- Add change password modal in sidebar for all users
- Add forgot password info modal on login page
- Show user groups in UsersPage with group assignment

Testing:
- Add integration tests for groups API
- Add tests for user-group assignments
- Add tests for change password endpoint
- Seed default groups in test fixtures

Closes #28 #161
maziggy hace 3 meses
padre
commit
89229a5ecc
Se han modificado 46 ficheros con 5110 adiciones y 601 borrados
  1. 8 0
      CHANGELOG.md
  2. 3 2
      README.md
  3. 45 18
      backend/app/api/routes/auth.py
  4. 316 0
      backend/app/api/routes/groups.py
  5. 91 18
      backend/app/api/routes/printers.py
  6. 63 0
      backend/app/api/routes/settings.py
  7. 138 55
      backend/app/api/routes/users.py
  8. 125 2
      backend/app/core/auth.py
  9. 106 0
      backend/app/core/database.py
  10. 392 0
      backend/app/core/permissions.py
  11. 2 0
      backend/app/main.py
  12. 3 0
      backend/app/models/__init__.py
  13. 54 0
      backend/app/models/group.py
  14. 82 3
      backend/app/models/user.py
  15. 21 1
      backend/app/schemas/auth.py
  16. 89 0
      backend/app/schemas/group.py
  17. 6 0
      backend/tests/conftest.py
  18. 330 1
      backend/tests/integration/test_auth_api.py
  19. 4 4
      frontend/src/App.tsx
  20. 169 0
      frontend/src/__tests__/contexts/AuthContext.test.tsx
  21. 84 0
      frontend/src/__tests__/mocks/handlers.ts
  22. 136 0
      frontend/src/__tests__/pages/GroupsPage.test.tsx
  23. 9 4
      frontend/src/__tests__/pages/LoginPage.test.tsx
  24. 121 2
      frontend/src/api/client.ts
  25. 2 0
      frontend/src/components/ContextMenu.tsx
  26. 27 12
      frontend/src/components/KProfilesView.tsx
  27. 216 38
      frontend/src/components/Layout.tsx
  28. 40 2
      frontend/src/contexts/AuthContext.tsx
  29. 94 12
      frontend/src/pages/ArchivesPage.tsx
  30. 138 46
      frontend/src/pages/FileManagerPage.tsx
  31. 496 0
      frontend/src/pages/GroupsPage.tsx
  32. 61 0
      frontend/src/pages/LoginPage.tsx
  33. 47 16
      frontend/src/pages/MaintenancePage.tsx
  34. 100 49
      frontend/src/pages/PrintersPage.tsx
  35. 46 8
      frontend/src/pages/ProfilesPage.tsx
  36. 51 14
      frontend/src/pages/ProjectDetailPage.tsx
  37. 41 10
      frontend/src/pages/ProjectsPage.tsx
  38. 28 9
      frontend/src/pages/QueuePage.tsx
  39. 1008 170
      frontend/src/pages/SettingsPage.tsx
  40. 6 2
      frontend/src/pages/StatsPage.tsx
  41. 310 101
      frontend/src/pages/UsersPage.tsx
  42. 0 0
      static/assets/index-B0_vH-u8.js
  43. 0 0
      static/assets/index-BrclLX7E.css
  44. 0 0
      static/assets/index-Bwf4poPr.css
  45. 0 0
      static/assets/index-DlQJz8pN.js
  46. 2 2
      static/index.html

+ 8 - 0
CHANGELOG.md

@@ -5,6 +5,14 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6-final] - Not released
 ## [0.1.6-final] - Not released
 
 
 ### New Features
 ### 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):
 - **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):
   - Checkbox option when uploading STL files to generate thumbnails automatically
   - Checkbox option when uploading STL files to generate thumbnails automatically
   - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button
   - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button

+ 3 - 2
README.md

@@ -152,9 +152,10 @@
 
 
 ### 🔒 Optional Authentication
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
 - 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
 - JWT tokens with secure password hashing
-- User management (create, edit, delete)
+- User management (create, edit, delete, groups)
 
 
 </td>
 </td>
 </tr>
 </tr>

+ 45 - 18
backend/app/api/routes/auth.py

@@ -3,6 +3,7 @@ from datetime import timedelta
 from fastapi import APIRouter, Depends, HTTPException, status
 from fastapi import APIRouter, Depends, HTTPException, status
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
     ACCESS_TOKEN_EXPIRE_MINUTES,
     ACCESS_TOKEN_EXPIRE_MINUTES,
@@ -13,9 +14,25 @@ from backend.app.core.auth import (
     get_user_by_username,
     get_user_by_username,
 )
 )
 from backend.app.core.database import get_db
 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.settings import Settings
 from backend.app.models.user import User
 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"])
 router = APIRouter(prefix="/auth", tags=["authentication"])
 
 
@@ -126,6 +143,14 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                         role="admin",
                         role="admin",
                         is_active=True,
                         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)
                     db.add(admin_user)
                     logger.info(f"Admin user added to session: {request.admin_username}")
                     logger.info(f"Admin user added to session: {request.admin_username}")
                     admin_created = True
                     admin_created = True
@@ -179,8 +204,12 @@ async def disable_auth(
 
 
     logger = logging.getLogger(__name__)
     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
     # Only admins can disable authentication
-    if current_user.role != "admin":
+    if not user.is_admin:
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_403_FORBIDDEN,
             status_code=status.HTTP_403_FORBIDDEN,
             detail="Only admins can disable authentication",
             detail="Only admins can disable authentication",
@@ -189,7 +218,7 @@ async def disable_auth(
     try:
     try:
         await set_auth_enabled(db, False)
         await set_auth_enabled(db, False)
         await db.commit()
         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}
         return {"message": "Authentication disabled successfully", "auth_enabled": False}
     except Exception as e:
     except Exception as e:
         await db.rollback()
         await db.rollback()
@@ -219,32 +248,30 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
             headers={"WWW-Authenticate": "Bearer"},
             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_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
 
 
     return LoginResponse(
     return LoginResponse(
         access_token=access_token,
         access_token=access_token,
         token_type="bearer",
         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)
 @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."""
     """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")
 @router.post("/logout")

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

+ 91 - 18
backend/app/api/routes/printers.py

@@ -8,9 +8,10 @@ from fastapi.responses import Response
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
-from backend.app.core.auth import RequireAdminIfAuthEnabled
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import settings
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.models.slot_preset import SlotPresetMapping
 from backend.app.schemas.printer import (
 from backend.app.schemas.printer import (
@@ -38,7 +39,10 @@ router = APIRouter(prefix="/printers", tags=["printers"])
 
 
 
 
 @router.get("/", response_model=list[PrinterResponse])
 @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."""
     """List all configured printers."""
     result = await db.execute(select(Printer).order_by(Printer.name))
     result = await db.execute(select(Printer).order_by(Printer.name))
     return list(result.scalars().all())
     return list(result.scalars().all())
@@ -47,8 +51,8 @@ async def list_printers(db: AsyncSession = Depends(get_db)):
 @router.post("/", response_model=PrinterResponse)
 @router.post("/", response_model=PrinterResponse)
 async def create_printer(
 async def create_printer(
     printer_data: PrinterCreate,
     printer_data: PrinterCreate,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _current_user=RequireAdminIfAuthEnabled(),
 ):
 ):
     """Add a new printer."""
     """Add a new printer."""
     # Check if serial number already exists
     # Check if serial number already exists
@@ -69,7 +73,9 @@ async def create_printer(
 
 
 
 
 @router.get("/usb-cameras")
 @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.
     """List available USB cameras connected to the system.
 
 
     Returns a list of detected V4L2 video devices with their info.
     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)
 @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."""
     """Get a specific printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     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(
 async def update_printer(
     printer_id: int,
     printer_id: int,
     printer_data: PrinterUpdate,
     printer_data: PrinterUpdate,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _current_user=RequireAdminIfAuthEnabled(),
 ):
 ):
     """Update a printer."""
     """Update a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -143,8 +153,8 @@ async def update_printer(
 async def delete_printer(
 async def delete_printer(
     printer_id: int,
     printer_id: int,
     delete_archives: bool = True,
     delete_archives: bool = True,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    _current_user=RequireAdminIfAuthEnabled(),
 ):
 ):
     """Delete a printer.
     """Delete a printer.
 
 
@@ -191,7 +201,11 @@ async def delete_printer(
 
 
 
 
 @router.get("/{printer_id}/status", response_model=PrinterStatus)
 @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."""
     """Get real-time status of a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     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")
 @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)."""
     """Request a full status refresh from the printer (sends pushall command)."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     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")
 @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."""
     """Manually connect to a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     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")
 @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."""
     """Manually disconnect from a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -474,6 +500,7 @@ async def test_printer_connection(
     ip_address: str,
     ip_address: str,
     serial_number: str,
     serial_number: str,
     access_code: str,
     access_code: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
 ):
 ):
     """Test connection to a printer without saving."""
     """Test connection to a printer without saving."""
     result = await printer_manager.test_connection(
     result = await printer_manager.test_connection(
@@ -492,6 +519,7 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 async def get_printer_cover(
 async def get_printer_cover(
     printer_id: int,
     printer_id: int,
     view: str | None = None,
     view: str | None = None,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get the cover image for the current print job.
     """Get the cover image for the current print job.
@@ -679,6 +707,7 @@ async def get_printer_cover(
 async def list_printer_files(
 async def list_printer_files(
     printer_id: int,
     printer_id: int,
     path: str = "/",
     path: str = "/",
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """List files on the printer at the specified path."""
     """List files on the printer at the specified path."""
@@ -703,6 +732,7 @@ async def list_printer_files(
 async def download_printer_file(
 async def download_printer_file(
     printer_id: int,
     printer_id: int,
     path: str,
     path: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Download a file from the printer."""
     """Download a file from the printer."""
@@ -743,6 +773,7 @@ async def download_printer_file(
 async def download_printer_files_as_zip(
 async def download_printer_files_as_zip(
     printer_id: int,
     printer_id: int,
     request: dict,
     request: dict,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Download multiple files from the printer as a ZIP archive."""
     """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(
 async def delete_printer_file(
     printer_id: int,
     printer_id: int,
     path: str,
     path: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete a file from the printer."""
     """Delete a file from the printer."""
@@ -805,6 +837,7 @@ async def delete_printer_file(
 @router.get("/{printer_id}/storage")
 @router.get("/{printer_id}/storage")
 async def get_printer_storage(
 async def get_printer_storage(
     printer_id: int,
     printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get storage information from the printer."""
     """Get storage information from the printer."""
@@ -824,7 +857,11 @@ async def get_printer_storage(
 
 
 
 
 @router.post("/{printer_id}/logging/enable")
 @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."""
     """Enable MQTT message logging for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     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")
 @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."""
     """Disable MQTT message logging for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     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")
 @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."""
     """Get MQTT message logs for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     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")
 @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."""
     """Clear MQTT message logs for a printer."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     printer = result.scalar_one_or_none()
@@ -900,6 +949,7 @@ async def set_print_option(
     enabled: bool,
     enabled: bool,
     print_halt: bool = True,
     print_halt: bool = True,
     sensitivity: str = "medium",
     sensitivity: str = "medium",
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Set an AI detection / print option on the printer.
     """Set an AI detection / print option on the printer.
@@ -972,6 +1022,7 @@ async def start_calibration(
     motor_noise: bool = False,
     motor_noise: bool = False,
     nozzle_offset: bool = False,
     nozzle_offset: bool = False,
     high_temp_heatbed: bool = False,
     high_temp_heatbed: bool = False,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Start printer calibration with selected options.
     """Start printer calibration with selected options.
@@ -1027,6 +1078,7 @@ async def start_calibration(
 @router.get("/{printer_id}/slot-presets")
 @router.get("/{printer_id}/slot-presets")
 async def get_slot_presets(
 async def get_slot_presets(
     printer_id: int,
     printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get all saved slot-to-preset mappings for a printer."""
     """Get all saved slot-to-preset mappings for a printer."""
@@ -1049,6 +1101,7 @@ async def get_slot_preset(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     tray_id: int,
     tray_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get the saved preset for a specific slot."""
     """Get the saved preset for a specific slot."""
@@ -1079,6 +1132,7 @@ async def save_slot_preset(
     tray_id: int,
     tray_id: int,
     preset_id: str,
     preset_id: str,
     preset_name: str,
     preset_name: str,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Save a preset mapping for a specific slot."""
     """Save a preset mapping for a specific slot."""
@@ -1128,6 +1182,7 @@ async def delete_slot_preset(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     tray_id: int,
     tray_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Delete a saved preset mapping for a slot."""
     """Delete a saved preset mapping for a slot."""
@@ -1164,6 +1219,7 @@ async def configure_ams_slot(
     kprofile_filament_id: str = Query(""),
     kprofile_filament_id: str = Query(""),
     kprofile_setting_id: str = Query(""),
     kprofile_setting_id: str = Query(""),
     k_value: float = Query(0.0),
     k_value: float = Query(0.0),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
 ):
 ):
     """Configure an AMS slot with a specific filament setting and K profile.
     """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")
 @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."""
     """Stop/cancel the current print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     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")
 @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."""
     """Pause the current print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     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")
 @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."""
     """Resume a paused print job."""
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     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(
 async def set_chamber_light(
     printer_id: int,
     printer_id: int,
     on: bool = Query(..., description="True to turn on, False to turn off"),
     on: bool = Query(..., description="True to turn on, False to turn off"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Turn the chamber light on or off."""
     """Turn the chamber light on or off."""
@@ -1446,6 +1515,7 @@ async def set_chamber_light(
 async def get_printable_objects(
 async def get_printable_objects(
     printer_id: int,
     printer_id: int,
     reload: bool = False,
     reload: bool = False,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Get the list of printable objects for the current print.
     """Get the list of printable objects for the current print.
@@ -1543,6 +1613,7 @@ async def get_printable_objects(
 async def skip_objects(
 async def skip_objects(
     printer_id: int,
     printer_id: int,
     object_ids: list[int],
     object_ids: list[int],
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Skip specific objects during the current print.
     """Skip specific objects during the current print.
@@ -1597,6 +1668,7 @@ async def refresh_ams_slot(
     printer_id: int,
     printer_id: int,
     ams_id: int,
     ams_id: int,
     slot_id: int,
     slot_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Re-read RFID for an AMS slot (triggers filament info refresh)."""
     """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")
 @router.get("/{printer_id}/runtime-debug")
 async def get_runtime_debug(
 async def get_runtime_debug(
     printer_id: int,
     printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """Debug endpoint: Get runtime tracking status for a printer."""
     """Debug endpoint: Get runtime tracking status for a printer."""

+ 63 - 0
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.external_link import ExternalLink
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig
 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.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification import NotificationProvider
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification_template import NotificationTemplate
@@ -259,6 +260,7 @@ async def export_backup(
     include_users: bool = Query(
     include_users: bool = Query(
         False, description="Include users (passwords not exported - users will need new passwords)"
         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)"),
     include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
 ):
 ):
     """Export selected data as JSON backup."""
     """Export selected data as JSON backup."""
@@ -860,11 +862,28 @@ async def export_backup(
                     "username": user.username,
                     "username": user.username,
                     "role": user.role,
                     "role": user.role,
                     "is_active": user.is_active,
                     "is_active": user.is_active,
+                    "groups": [g.name for g in user.groups],
                     # password_hash intentionally not exported for security
                     # password_hash intentionally not exported for security
                 }
                 }
             )
             )
         backup["included"].append("users")
         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
     # GitHub backup configuration
     if include_github_backup:
     if include_github_backup:
         result = await db.execute(select(GitHubBackupConfig).limit(1))
         result = await db.execute(select(GitHubBackupConfig).limit(1))
@@ -982,6 +1001,7 @@ async def import_backup(
         "projects": 0,
         "projects": 0,
         "pending_uploads": 0,
         "pending_uploads": 0,
         "users": 0,
         "users": 0,
+        "groups": 0,
         "github_backup": 0,
         "github_backup": 0,
     }
     }
     skipped = {
     skipped = {
@@ -997,6 +1017,7 @@ async def import_backup(
         "projects": 0,
         "projects": 0,
         "pending_uploads": 0,
         "pending_uploads": 0,
         "users": 0,
         "users": 0,
+        "groups": 0,
         "github_backup": 0,
         "github_backup": 0,
     }
     }
     skipped_details = {
     skipped_details = {
@@ -1010,6 +1031,7 @@ async def import_backup(
         "projects": [],
         "projects": [],
         "pending_uploads": [],
         "pending_uploads": [],
         "users": [],
         "users": [],
+        "groups": [],
     }
     }
 
 
     # Restore settings (always overwrites)
     # Restore settings (always overwrites)
@@ -1977,6 +1999,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)
     # 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
     # Users are skipped by default since they have no passwords; admin must recreate them
     new_users: list[str] = []
     new_users: list[str] = []
@@ -1990,6 +2045,10 @@ async def import_backup(
                 if overwrite:
                 if overwrite:
                     existing.role = user_data.get("role", "user")
                     existing.role = user_data.get("role", "user")
                     existing.is_active = user_data.get("is_active", True)
                     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
                     # Don't change password - keep existing
                     restored["users"] += 1
                     restored["users"] += 1
                 else:
                 else:
@@ -2007,6 +2066,10 @@ async def import_backup(
                     role=user_data.get("role", "user"),
                     role=user_data.get("role", "user"),
                     is_active=user_data.get("is_active", True),
                     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)
                 db.add(user)
                 restored["users"] += 1
                 restored["users"] += 1
                 new_users.append(f"{user_data['username']} (temp password: {temp_password})")
                 new_users.append(f"{user_data['username']} (temp password: {temp_password})")

+ 138 - 55
backend/app/api/routes/users.py

@@ -1,44 +1,57 @@
 from fastapi import APIRouter, Depends, HTTPException, status
 from fastapi import APIRouter, Depends, HTTPException, status
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 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.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.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"])
 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])
 @router.get("/", response_model=list[UserResponse])
 @router.get("/", response_model=list[UserResponse])
 async def list_users(
 async def list_users(
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
     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()
     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)
 @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(
 async def create_user(
     user_data: UserCreate,
     user_data: UserCreate,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """Create a new user (admin only)."""
+    """Create a new user."""
     # Check if username already exists
     # Check if username already exists
     existing_user = await db.execute(select(User).where(User.username == user_data.username))
     existing_user = await db.execute(select(User).where(User.username == user_data.username))
     if existing_user.scalar_one_or_none():
     if existing_user.scalar_one_or_none():
@@ -60,27 +73,33 @@ async def create_user(
         role=user_data.role,
         role=user_data.role,
         is_active=True,
         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)
     db.add(new_user)
     await db.commit()
     await db.commit()
     await db.refresh(new_user)
     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)
 @router.get("/{user_id}", response_model=UserResponse)
 async def get_user(
 async def get_user(
     user_id: int,
     user_id: int,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
     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()
     user = result.scalar_one_or_none()
     if not user:
     if not user:
         raise HTTPException(
         raise HTTPException(
@@ -88,24 +107,18 @@ async def get_user(
             detail="User not found",
             detail="User not found",
         )
         )
 
 
-    return UserResponse(
-        id=user.id,
-        username=user.username,
-        role=user.role,
-        is_active=user.is_active,
-        created_at=user.created_at.isoformat(),
-    )
+    return _user_to_response(user)
 
 
 
 
 @router.patch("/{user_id}", response_model=UserResponse)
 @router.patch("/{user_id}", response_model=UserResponse)
 async def update_user(
 async def update_user(
     user_id: int,
     user_id: int,
     user_data: UserUpdate,
     user_data: UserUpdate,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
     db: AsyncSession = Depends(get_db),
     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()
     user = result.scalar_one_or_none()
     if not user:
     if not user:
         raise HTTPException(
         raise HTTPException(
@@ -114,10 +127,21 @@ async def update_user(
         )
         )
 
 
     # Prevent deactivating the last admin
     # 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_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(
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Cannot deactivate the last admin user",
                 detail="Cannot deactivate the last admin user",
@@ -157,26 +181,31 @@ async def update_user(
     if user_data.is_active is not None:
     if user_data.is_active is not None:
         user.is_active = user_data.is_active
         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.commit()
     await db.refresh(user)
     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)
 @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_user(
 async def delete_user(
     user_id: int,
     user_id: int,
-    current_user: User = RequireAdmin(),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     db: AsyncSession = Depends(get_db),
     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()
     user = result.scalar_one_or_none()
     if not user:
     if not user:
         raise HTTPException(
         raise HTTPException(
@@ -185,17 +214,28 @@ async def delete_user(
         )
         )
 
 
     # Prevent deleting the last admin
     # 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_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(
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Cannot delete the last admin user",
                 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(
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             status_code=status.HTTP_400_BAD_REQUEST,
             detail="Cannot delete your own account",
             detail="Cannot delete your own account",
@@ -203,3 +243,46 @@ async def delete_user(
 
 
     await db.delete(user)
     await db.delete(user)
     await db.commit()
     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"}

+ 125 - 2
backend/app/core/auth.py

@@ -11,8 +11,10 @@ from jwt.exceptions import PyJWTError as JWTError
 from passlib.context import CryptContext
 from passlib.context import CryptContext
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 
 from backend.app.core.database import async_session, get_db
 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.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 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:
 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()
     return result.scalar_one_or_none()
 
 
 
 
@@ -347,3 +349,124 @@ def RequireAdmin():
 def RequireAdminIfAuthEnabled():
 def RequireAdminIfAuthEnabled():
     """Dependency that requires admin role if auth is enabled."""
     """Dependency that requires admin role if auth is enabled."""
     return Depends(require_admin_if_auth_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))

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

@@ -39,6 +39,7 @@ async def init_db():
         external_link,
         external_link,
         filament,
         filament,
         github_backup,
         github_backup,
+        group,
         kprofile_note,
         kprofile_note,
         library,
         library,
         maintenance,
         maintenance,
@@ -62,6 +63,9 @@ async def init_db():
     # Seed default notification templates
     # Seed default notification templates
     await seed_notification_templates()
     await seed_notification_templates()
 
 
+    # Seed default groups and migrate existing users
+    await seed_default_groups()
+
 
 
 async def run_migrations(conn):
 async def run_migrations(conn):
     """Add new columns to existing tables if they don't exist."""
     """Add new columns to existing tables if they don't exist."""
@@ -800,6 +804,41 @@ async def run_migrations(conn):
         except Exception:
         except Exception:
             pass
             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():
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
     """Seed default notification templates if they don't exist."""
@@ -837,3 +876,70 @@ async def seed_notification_templates():
                     session.add(template)
                     session.add(template)
 
 
         await session.commit()
         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()

+ 392 - 0
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,
+    },
+}

+ 2 - 0
backend/app/main.py

@@ -183,6 +183,7 @@ from backend.app.api.routes import (
     filaments,
     filaments,
     firmware,
     firmware,
     github_backup,
     github_backup,
+    groups,
     kprofiles,
     kprofiles,
     library,
     library,
     maintenance,
     maintenance,
@@ -2493,6 +2494,7 @@ app = FastAPI(
 # API routes
 # API routes
 app.include_router(auth.router, prefix=app_settings.api_prefix)
 app.include_router(auth.router, prefix=app_settings.api_prefix)
 app.include_router(users.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(printers.router, prefix=app_settings.api_prefix)
 app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(archives.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)
 app.include_router(filaments.router, prefix=app_settings.api_prefix)

+ 3 - 0
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.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 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.kprofile_note import KProfileNote
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
@@ -34,6 +35,8 @@ __all__ = [
     "LibraryFolder",
     "LibraryFolder",
     "LibraryFile",
     "LibraryFile",
     "User",
     "User",
+    "Group",
+    "user_groups",
     "GitHubBackupConfig",
     "GitHubBackupConfig",
     "GitHubBackupLog",
     "GitHubBackupLog",
 ]
 ]

+ 54 - 0
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"<Group {self.name}>"

+ 82 - 3
backend/app/models/user.py

@@ -1,20 +1,99 @@
+from __future__ import annotations
+
 from datetime import datetime
 from datetime import datetime
+from typing import TYPE_CHECKING
 
 
 from sqlalchemy import DateTime, String, func
 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
 from backend.app.core.database import Base
 
 
+if TYPE_CHECKING:
+    from backend.app.models.group import Group
+
 
 
 class User(Base):
 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"
     __tablename__ = "users"
 
 
     id: Mapped[int] = mapped_column(primary_key=True)
     id: Mapped[int] = mapped_column(primary_key=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     password_hash: Mapped[str] = mapped_column(String(255))
     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)
     is_active: Mapped[bool] = mapped_column(default=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     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())
     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"<User {self.username}>"

+ 21 - 1
backend/app/schemas/auth.py

@@ -1,6 +1,16 @@
 from pydantic import BaseModel
 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):
 class LoginRequest(BaseModel):
     username: str
     username: str
     password: str
     password: str
@@ -16,6 +26,7 @@ class UserCreate(BaseModel):
     username: str
     username: str
     password: str
     password: str
     role: str = "user"
     role: str = "user"
+    group_ids: list[int] | None = None
 
 
 
 
 class UserUpdate(BaseModel):
 class UserUpdate(BaseModel):
@@ -23,19 +34,28 @@ class UserUpdate(BaseModel):
     password: str | None = None
     password: str | None = None
     role: str | None = None
     role: str | None = None
     is_active: bool | None = None
     is_active: bool | None = None
+    group_ids: list[int] | None = None
 
 
 
 
 class UserResponse(BaseModel):
 class UserResponse(BaseModel):
     id: int
     id: int
     username: str
     username: str
-    role: str
+    role: str  # Deprecated, kept for backward compatibility
     is_active: bool
     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
     created_at: str
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
 
 
 
 
+class ChangePasswordRequest(BaseModel):
+    current_password: str
+    new_password: str
+
+
 class SetupRequest(BaseModel):
 class SetupRequest(BaseModel):
     auth_enabled: bool
     auth_enabled: bool
     admin_username: str | None = None
     admin_username: str | None = None

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

+ 6 - 0
backend/tests/conftest.py

@@ -67,6 +67,7 @@ async def test_engine():
         archive,
         archive,
         external_link,
         external_link,
         filament,
         filament,
+        group,
         kprofile_note,
         kprofile_note,
         maintenance,
         maintenance,
         notification,
         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.core.auth.async_session", test_async_session),
         patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
         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:
         async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
             yield client
             yield client
 
 

+ 330 - 1
backend/tests/integration/test_auth_api.py

@@ -205,7 +205,18 @@ class TestUsersAPI:
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_list_users_requires_auth(self, async_client: AsyncClient):
     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/")
         response = await async_client.get("/api/v1/users/")
 
 
         assert response.status_code == 401
         assert response.status_code == 401
@@ -360,3 +371,321 @@ class TestAuthDisableAPI:
         # Verify auth is now disabled
         # Verify auth is now disabled
         status_response = await async_client.get("/api/v1/auth/status")
         status_response = await async_client.get("/api/v1/auth/status")
         assert status_response.json()["auth_enabled"] is False
         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

+ 4 - 4
frontend/src/App.tsx

@@ -16,7 +16,6 @@ import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
 import { LoginPage } from './pages/LoginPage';
 import { SetupPage } from './pages/SetupPage';
 import { SetupPage } from './pages/SetupPage';
-import { UsersPage } from './pages/UsersPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -51,7 +50,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
 }
 }
 
 
 function AdminRoute({ children }: { children: React.ReactNode }) {
 function AdminRoute({ children }: { children: React.ReactNode }) {
-  const { authEnabled, loading, user } = useAuth();
+  const { authEnabled, loading, user, isAdmin } = useAuth();
 
 
   if (loading) {
   if (loading) {
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
     return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
@@ -68,7 +67,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
   }
   }
 
 
   // If user is not admin, redirect to home
   // If user is not admin, redirect to home
-  if (user.role !== 'admin') {
+  if (!isAdmin) {
     return <Navigate to="/" replace />;
     return <Navigate to="/" replace />;
   }
   }
 
 
@@ -121,7 +120,8 @@ function App() {
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
                   <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
-                  <Route path="users" element={<UsersPage />} />
+                  <Route path="users" element={<Navigate to="/settings?tab=users" replace />} />
+                  <Route path="groups" element={<Navigate to="/settings?tab=users" replace />} />
                   <Route path="system" element={<SystemInfoPage />} />
                   <Route path="system" element={<SystemInfoPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                 </Route>
                 </Route>

+ 169 - 0
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 (
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <ThemeProvider>
+            <ToastProvider>
+              <AuthProvider>{children}</AuthProvider>
+            </ToastProvider>
+          </ThemeProvider>
+        </BrowserRouter>
+      </QueryClientProvider>
+    );
+  };
+}
+
+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);
+    });
+  });
+});

+ 84 - 0
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<string, unknown>;
+    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<string, unknown>;
+    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
   // Version / Health
   // ========================================================================
   // ========================================================================

+ 136 - 0
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(<GroupsPage />);
+
+      // Page should render without errors
+      await waitFor(() => {
+        expect(document.body).toBeInTheDocument();
+      });
+    });
+
+    it('renders group names from API', async () => {
+      render(<GroupsPage />);
+
+      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(<GroupsPage />);
+
+      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(<GroupsPage />);
+
+      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(<GroupsPage />);
+
+      await waitFor(() => {
+        expect(permissionsFetched).toBe(true);
+      });
+    });
+  });
+});

+ 9 - 4
frontend/src/__tests__/pages/LoginPage.test.tsx

@@ -119,11 +119,13 @@ describe('LoginPage', () => {
 
 
     it('shows loading state during login', async () => {
     it('shows loading state during login', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();
+      let resolveLogin: () => void;
+      const loginPromise = new Promise<void>(resolve => { resolveLogin = resolve; });
 
 
-      // Slow login endpoint
+      // Slow login endpoint that we control
       server.use(
       server.use(
         http.post('/api/v1/auth/login', async () => {
         http.post('/api/v1/auth/login', async () => {
-          await new Promise(resolve => setTimeout(resolve, 100));
+          await loginPromise;
           return HttpResponse.json({
           return HttpResponse.json({
             access_token: 'test-token',
             access_token: 'test-token',
             token_type: 'bearer',
             token_type: 'bearer',
@@ -148,10 +150,13 @@ describe('LoginPage', () => {
       await user.type(screen.getByLabelText(/Password/i), 'testpass');
       await user.type(screen.getByLabelText(/Password/i), 'testpass');
       await user.click(screen.getByRole('button', { name: /Sign in/i }));
       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(() => {
       await waitFor(() => {
-        expect(screen.getByRole('button')).toBeDisabled();
+        expect(screen.getByRole('button', { name: /Logging in/i })).toBeInTheDocument();
       });
       });
+
+      // Release the login request
+      resolveLogin!();
     });
     });
   });
   });
 });
 });

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

@@ -50,6 +50,12 @@ async function request<T>(
     throw new Error(message);
     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();
   return await response.json();
 }
 }
 
 
@@ -1658,6 +1664,82 @@ export interface ExternalLinkUpdate {
   icon?: string;
   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
 // Auth types
 export interface LoginRequest {
 export interface LoginRequest {
   username: string;
   username: string;
@@ -1673,8 +1755,11 @@ export interface LoginResponse {
 export interface UserResponse {
 export interface UserResponse {
   id: number;
   id: number;
   username: string;
   username: string;
-  role: string;
+  role: string;  // Deprecated, kept for backward compatibility
   is_active: boolean;
   is_active: boolean;
+  is_admin: boolean;  // Computed from role and group membership
+  groups: GroupBrief[];
+  permissions: Permission[];  // All permissions from groups
   created_at: string;
   created_at: string;
 }
 }
 
 
@@ -1682,6 +1767,7 @@ export interface UserCreate {
   username: string;
   username: string;
   password: string;
   password: string;
   role: string;
   role: string;
+  group_ids?: number[];
 }
 }
 
 
 export interface UserUpdate {
 export interface UserUpdate {
@@ -1689,6 +1775,7 @@ export interface UserUpdate {
   password?: string;
   password?: string;
   role?: string;
   role?: string;
   is_active?: boolean;
   is_active?: boolean;
+  group_ids?: number[];
 }
 }
 
 
 export interface SetupRequest {
 export interface SetupRequest {
@@ -1731,7 +1818,7 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
 
 
-  // Users (admin only)
+  // Users
   getUsers: () => request<UserResponse[]>('/users/'),
   getUsers: () => request<UserResponse[]>('/users/'),
   getUser: (id: number) => request<UserResponse>(`/users/${id}`),
   getUser: (id: number) => request<UserResponse>(`/users/${id}`),
   createUser: (data: UserCreate) =>
   createUser: (data: UserCreate) =>
@@ -1748,6 +1835,38 @@ export const api = {
     request<void>(`/users/${id}`, {
     request<void>(`/users/${id}`, {
       method: 'DELETE',
       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<PermissionsListResponse>('/groups/permissions'),
+  getGroups: () => request<Group[]>('/groups/'),
+  getGroup: (id: number) => request<GroupDetail>(`/groups/${id}`),
+  createGroup: (data: GroupCreate) =>
+    request<Group>('/groups/', {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
+  updateGroup: (id: number, data: GroupUpdate) =>
+    request<Group>(`/groups/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  deleteGroup: (id: number) =>
+    request<void>(`/groups/${id}`, {
+      method: 'DELETE',
+    }),
+  addUserToGroup: (groupId: number, userId: number) =>
+    request<void>(`/groups/${groupId}/users/${userId}`, {
+      method: 'POST',
+    }),
+  removeUserFromGroup: (groupId: number, userId: number) =>
+    request<void>(`/groups/${groupId}/users/${userId}`, {
+      method: 'DELETE',
+    }),
 
 
   // Printers
   // Printers
   getPrinters: () => request<Printer[]>('/printers/'),
   getPrinters: () => request<Printer[]>('/printers/'),

+ 2 - 0
frontend/src/components/ContextMenu.tsx

@@ -9,6 +9,7 @@ export interface ContextMenuItem {
   disabled?: boolean;
   disabled?: boolean;
   divider?: boolean;
   divider?: boolean;
   submenu?: ContextMenuItem[];
   submenu?: ContextMenuItem[];
+  title?: string;
 }
 }
 
 
 interface ContextMenuProps {
 interface ContextMenuProps {
@@ -163,6 +164,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
                 }
                 }
               }}
               }}
               disabled={item.disabled}
               disabled={item.disabled}
+              title={item.title}
               className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
               className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
                 item.disabled
                 item.disabled
                   ? 'text-bambu-gray cursor-not-allowed'
                   ? 'text-bambu-gray cursor-not-allowed'

+ 27 - 12
frontend/src/components/KProfilesView.tsx

@@ -19,10 +19,11 @@ import {
   StickyNote,
   StickyNote,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 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 { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 
 interface KProfileCardProps {
 interface KProfileCardProps {
   profile: KProfile;
   profile: KProfile;
@@ -154,6 +155,7 @@ interface KProfileModalProps {
   onClose: () => void;
   onClose: () => void;
   onSave: () => void;
   onSave: () => void;
   onSaveNote?: (settingId: string, note: string) => void;  // Callback to save note
   onSaveNote?: (settingId: string, note: string) => void;  // Callback to save note
+  hasPermission: (permission: Permission) => boolean;
 }
 }
 
 
 function KProfileModal({
 function KProfileModal({
@@ -167,6 +169,7 @@ function KProfileModal({
   onClose,
   onClose,
   onSave,
   onSave,
   onSaveNote,
   onSaveNote,
+  hasPermission,
 }: KProfileModalProps) {
 }: KProfileModalProps) {
   const { showToast } = useToast();
   const { showToast } = useToast();
 
 
@@ -590,7 +593,8 @@ function KProfileModal({
                   type="button"
                   type="button"
                   variant="secondary"
                   variant="secondary"
                   onClick={() => setShowDeleteConfirm(true)}
                   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"
                   className="text-red-500 hover:bg-red-500/10"
                 >
                 >
                   {deleteMutation.isPending ? (
                   {deleteMutation.isPending ? (
@@ -611,7 +615,8 @@ function KProfileModal({
               </Button>
               </Button>
               <Button
               <Button
                 type="submit"
                 type="submit"
-                disabled={saveMutation.isPending || isSyncing}
+                disabled={saveMutation.isPending || isSyncing || !hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create')}
+                title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? `You do not have permission to ${profile ? 'update' : 'create'} K-profiles` : undefined}
                 className="flex-1"
                 className="flex-1"
               >
               >
                 {saveMutation.isPending ? (
                 {saveMutation.isPending ? (
@@ -687,6 +692,7 @@ const STORAGE_KEYS = {
 
 
 export function KProfilesView() {
 export function KProfilesView() {
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
   // Load nozzle diameter from localStorage
   // Load nozzle diameter from localStorage
   const [nozzleDiameter, setNozzleDiameter] = useState(() => {
   const [nozzleDiameter, setNozzleDiameter] = useState(() => {
@@ -1139,12 +1145,17 @@ export function KProfilesView() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={() => refetchProfiles()}
             onClick={() => refetchProfiles()}
-            disabled={isFetching}
+            disabled={isFetching || !hasPermission('kprofiles:read')}
+            title={!hasPermission('kprofiles:read') ? 'You do not have permission to refresh profiles' : undefined}
           >
           >
             <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
             <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
             Refresh
             Refresh
           </Button>
           </Button>
-          <Button onClick={() => setShowAddModal(true)}>
+          <Button
+            onClick={() => setShowAddModal(true)}
+            disabled={!hasPermission('kprofiles:create')}
+            title={!hasPermission('kprofiles:create') ? 'You do not have permission to add profiles' : undefined}
+          >
             <Plus className="w-4 h-4" />
             <Plus className="w-4 h-4" />
             Add Profile
             Add Profile
           </Button>
           </Button>
@@ -1205,8 +1216,8 @@ export function KProfilesView() {
         <Button
         <Button
           variant="secondary"
           variant="secondary"
           onClick={handleExport}
           onClick={handleExport}
-          disabled={!kprofiles?.profiles?.length}
-          title="Export profiles to JSON"
+          disabled={!kprofiles?.profiles?.length || !hasPermission('kprofiles:read')}
+          title={!hasPermission('kprofiles:read') ? 'You do not have permission to export profiles' : 'Export profiles to JSON'}
         >
         >
           <Download className="w-4 h-4" />
           <Download className="w-4 h-4" />
           Export
           Export
@@ -1214,7 +1225,8 @@ export function KProfilesView() {
         <Button
         <Button
           variant="secondary"
           variant="secondary"
           onClick={handleImport}
           onClick={handleImport}
-          title="Import profiles from JSON"
+          disabled={!hasPermission('kprofiles:create')}
+          title={!hasPermission('kprofiles:create') ? 'You do not have permission to import profiles' : 'Import profiles from JSON'}
         >
         >
           <Upload className="w-4 h-4" />
           <Upload className="w-4 h-4" />
           Import
           Import
@@ -1233,9 +1245,9 @@ export function KProfilesView() {
             <Button
             <Button
               variant="secondary"
               variant="secondary"
               onClick={handleBulkDelete}
               onClick={handleBulkDelete}
-              disabled={selectedProfiles.size === 0}
+              disabled={selectedProfiles.size === 0 || !hasPermission('kprofiles:delete')}
               className="text-red-500 hover:bg-red-500/10"
               className="text-red-500 hover:bg-red-500/10"
-              title={`Delete ${selectedProfiles.size} selected profiles`}
+              title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : `Delete ${selectedProfiles.size} selected profiles`}
             >
             >
               <Trash2 className="w-4 h-4" />
               <Trash2 className="w-4 h-4" />
               Delete ({selectedProfiles.size})
               Delete ({selectedProfiles.size})
@@ -1255,8 +1267,8 @@ export function KProfilesView() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={() => setSelectionMode(true)}
             onClick={() => setSelectionMode(true)}
-            disabled={!filteredProfiles.length}
-            title="Enter selection mode for bulk delete"
+            disabled={!filteredProfiles.length || !hasPermission('kprofiles:delete')}
+            title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : 'Enter selection mode for bulk delete'}
           >
           >
             <CheckSquare className="w-4 h-4" />
             <CheckSquare className="w-4 h-4" />
             Select
             Select
@@ -1384,6 +1396,7 @@ export function KProfilesView() {
             initialNote={note}
             initialNote={note}
             initialNoteKey={key}
             initialNoteKey={key}
             onSaveNote={handleSaveNote}
             onSaveNote={handleSaveNote}
+            hasPermission={hasPermission}
             onClose={() => {
             onClose={() => {
               console.log('[KProfiles] Edit modal onClose - refetching profiles...');
               console.log('[KProfiles] Edit modal onClose - refetching profiles...');
               setEditingProfile(null);
               setEditingProfile(null);
@@ -1405,6 +1418,7 @@ export function KProfilesView() {
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
           isDualNozzle={isDualNozzle}
           isDualNozzle={isDualNozzle}
           onSaveNote={handleSaveNote}
           onSaveNote={handleSaveNote}
+          hasPermission={hasPermission}
           onClose={() => {
           onClose={() => {
             setShowAddModal(false);
             setShowAddModal(false);
             refetchProfiles();  // Refetch after close
             refetchProfiles();  // Refetch after close
@@ -1424,6 +1438,7 @@ export function KProfilesView() {
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
           existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
           isDualNozzle={isDualNozzle}
           isDualNozzle={isDualNozzle}
           onSaveNote={handleSaveNote}
           onSaveNote={handleSaveNote}
+          hasPermission={hasPermission}
           // Pass profile data but without slot_id to create a new profile
           // Pass profile data but without slot_id to create a new profile
           profile={{
           profile={{
             ...copyingProfile,
             ...copyingProfile,

+ 216 - 38
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -10,6 +10,9 @@ import { api, supportApi, pendingUploadsApi } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { getIconByName } from './IconPicker';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { Card, CardHeader, CardContent } from './Card';
+import { Button } from './Button';
 
 
 interface NavItem {
 interface NavItem {
   id: string;
   id: string;
@@ -69,7 +72,11 @@ export function Layout() {
   const { mode, toggleMode } = useTheme();
   const { mode, toggleMode } = useTheme();
   const { t } = useTranslation();
   const { t } = useTranslation();
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
-  const { user, authEnabled, logout } = useAuth();
+  const { user, authEnabled, logout, hasPermission } = useAuth();
+  const { showToast } = useToast();
+  const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
+  const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
+  const [changePasswordLoading, setChangePasswordLoading] = useState(false);
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
     const stored = localStorage.getItem('sidebarExpanded');
     const stored = localStorage.getItem('sidebarExpanded');
     return stored !== 'false';
     return stored !== 'false';
@@ -564,17 +571,26 @@ export function Layout() {
                     )}
                     )}
                   </div>
                   </div>
                 )}
                 )}
-                <NavLink
-                  to="/system"
-                  className={({ isActive }) =>
-                    `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')}
-                >
-                  <Info className="w-5 h-5" />
-                </NavLink>
+                {hasPermission('system:read') ? (
+                  <NavLink
+                    to="/system"
+                    className={({ isActive }) =>
+                      `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')}
+                  >
+                    <Info className="w-5 h-5" />
+                  </NavLink>
+                ) : (
+                  <span
+                    className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
+                    title="You do not have permission to view system information"
+                  >
+                    <Info className="w-5 h-5" />
+                  </span>
+                )}
                 <a
                 <a
                   href="https://github.com/maziggy/bambuddy"
                   href="https://github.com/maziggy/bambuddy"
                   target="_blank"
                   target="_blank"
@@ -599,13 +615,22 @@ export function Layout() {
                   {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                   {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 </button>
                 </button>
                 {authEnabled && user && (
                 {authEnabled && user && (
-                  <button
-                    onClick={logout}
-                    className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                    title={t('nav.logout', { defaultValue: 'Logout' })}
-                  >
-                    <LogOut className="w-5 h-5" />
-                  </button>
+                  <>
+                    <button
+                      onClick={() => setShowChangePasswordModal(true)}
+                      className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                      title="Change Password"
+                    >
+                      <Key className="w-5 h-5" />
+                    </button>
+                    <button
+                      onClick={logout}
+                      className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                      title={t('nav.logout', { defaultValue: 'Logout' })}
+                    >
+                      <LogOut className="w-5 h-5" />
+                    </button>
+                  </>
                 )}
                 )}
               </div>
               </div>
               {/* Bottom row: version */}
               {/* Bottom row: version */}
@@ -650,17 +675,26 @@ export function Layout() {
                   )}
                   )}
                 </div>
                 </div>
               )}
               )}
-              <NavLink
-                to="/system"
-                className={({ isActive }) =>
-                  `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')}
-              >
-                <Info className="w-5 h-5" />
-              </NavLink>
+              {hasPermission('system:read') ? (
+                <NavLink
+                  to="/system"
+                  className={({ isActive }) =>
+                    `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')}
+                >
+                  <Info className="w-5 h-5" />
+                </NavLink>
+              ) : (
+                <span
+                  className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
+                  title="You do not have permission to view system information"
+                >
+                  <Info className="w-5 h-5" />
+                </span>
+              )}
               <a
               <a
                 href="https://github.com/maziggy/bambuddy"
                 href="https://github.com/maziggy/bambuddy"
                 target="_blank"
                 target="_blank"
@@ -685,13 +719,22 @@ export function Layout() {
                 {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
               </button>
               </button>
               {authEnabled && user && (
               {authEnabled && user && (
-                <button
-                  onClick={logout}
-                  className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title={t('nav.logout', { defaultValue: 'Logout' })}
-                >
-                  <LogOut className="w-5 h-5" />
-                </button>
+                <>
+                  <button
+                    onClick={() => setShowChangePasswordModal(true)}
+                    className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                    title="Change Password"
+                  >
+                    <Key className="w-5 h-5" />
+                  </button>
+                  <button
+                    onClick={logout}
+                    className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                    title={t('nav.logout', { defaultValue: 'Logout' })}
+                  >
+                    <LogOut className="w-5 h-5" />
+                  </button>
+                </>
               )}
               )}
             </div>
             </div>
           )}
           )}
@@ -800,6 +843,141 @@ export function Layout() {
           </div>
           </div>
         </div>
         </div>
       )}
       )}
+
+      {/* Change Password Modal */}
+      {showChangePasswordModal && (
+        <div
+          className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+          onClick={() => {
+            setShowChangePasswordModal(false);
+            setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
+          }}
+        >
+          <Card
+            className="w-full max-w-md"
+            onClick={(e: React.MouseEvent) => e.stopPropagation()}
+          >
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Key className="w-5 h-5 text-bambu-green" />
+                  <h2 className="text-lg font-semibold text-white">Change Password</h2>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => {
+                    setShowChangePasswordModal(false);
+                    setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
+                  }}
+                >
+                  <X className="w-5 h-5" />
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div className="space-y-4">
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Current Password
+                  </label>
+                  <input
+                    type="password"
+                    value={changePasswordData.currentPassword}
+                    onChange={(e) => 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"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    New Password
+                  </label>
+                  <input
+                    type="password"
+                    value={changePasswordData.newPassword}
+                    onChange={(e) => 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}
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Confirm New Password
+                  </label>
+                  <input
+                    type="password"
+                    value={changePasswordData.confirmPassword}
+                    onChange={(e) => 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 && (
+                    <p className="text-red-400 text-xs mt-1">Passwords do not match</p>
+                  )}
+                </div>
+              </div>
+              <div className="mt-6 flex justify-end gap-3">
+                <Button
+                  variant="secondary"
+                  onClick={() => {
+                    setShowChangePasswordModal(false);
+                    setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
+                  }}
+                >
+                  Cancel
+                </Button>
+                <Button
+                  onClick={async () => {
+                    if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
+                      showToast('Passwords do not match', 'error');
+                      return;
+                    }
+                    if (changePasswordData.newPassword.length < 6) {
+                      showToast('Password must be at least 6 characters', 'error');
+                      return;
+                    }
+                    setChangePasswordLoading(true);
+                    try {
+                      await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
+                      showToast('Password changed successfully', 'success');
+                      setShowChangePasswordModal(false);
+                      setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
+                    } catch (error: unknown) {
+                      const message = error instanceof Error ? error.message : 'Failed to change password';
+                      showToast(message, 'error');
+                    } finally {
+                      setChangePasswordLoading(false);
+                    }
+                  }}
+                  disabled={changePasswordLoading || !changePasswordData.currentPassword || !changePasswordData.newPassword || changePasswordData.newPassword !== changePasswordData.confirmPassword || changePasswordData.newPassword.length < 6}
+                >
+                  {changePasswordLoading ? (
+                    <>
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      Changing...
+                    </>
+                  ) : (
+                    <>
+                      <Key className="w-4 h-4" />
+                      Change Password
+                    </>
+                  )}
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 40 - 2
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 { api, getAuthToken, setAuthToken } from '../api/client';
-import type { UserResponse } from '../api/client';
+import type { Permission, UserResponse } from '../api/client';
 
 
 interface AuthContextType {
 interface AuthContextType {
   user: UserResponse | null;
   user: UserResponse | null;
   authEnabled: boolean;
   authEnabled: boolean;
   requiresSetup: boolean;
   requiresSetup: boolean;
   loading: boolean;
   loading: boolean;
+  isAdmin: boolean;
   login: (username: string, password: string) => Promise<void>;
   login: (username: string, password: string) => Promise<void>;
   logout: () => void;
   logout: () => void;
   refreshUser: () => Promise<void>;
   refreshUser: () => Promise<void>;
   refreshAuth: () => Promise<void>;
   refreshAuth: () => Promise<void>;
+  hasPermission: (permission: Permission) => boolean;
+  hasAnyPermission: (...permissions: Permission[]) => boolean;
+  hasAllPermissions: (...permissions: Permission[]) => boolean;
 }
 }
 
 
 const AuthContext = createContext<AuthContextType | undefined>(undefined);
 const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -122,6 +126,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     await checkAuthStatus();
     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 (
   return (
     <AuthContext.Provider
     <AuthContext.Provider
       value={{
       value={{
@@ -129,10 +163,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         authEnabled,
         authEnabled,
         requiresSetup,
         requiresSetup,
         loading,
         loading,
+        isAdmin,
         login,
         login,
         logout,
         logout,
         refreshUser,
         refreshUser,
         refreshAuth,
         refreshAuth,
+        hasPermission,
+        hasAnyPermission,
+        hasAllPermissions,
       }}
       }}
     >
     >
       {children}
       {children}

+ 94 - 12
frontend/src/pages/ArchivesPage.tsx

@@ -67,6 +67,7 @@ import { TimelapseViewer } from '../components/TimelapseViewer';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { CompareArchivesModal } from '../components/CompareArchivesModal';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 
 function formatFileSize(bytes: number): string {
 function formatFileSize(bytes: number): string {
   if (bytes < 1024) return `${bytes} B`;
   if (bytes < 1024) return `${bytes} B`;
@@ -120,6 +121,7 @@ function ArchiveCard({
 
 
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const isMobile = useIsMobile();
   const isMobile = useIsMobile();
   const [showViewer, setShowViewer] = useState(false);
   const [showViewer, setShowViewer] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
@@ -282,11 +284,15 @@ function ArchiveCard({
         label: 'Print',
         label: 'Print',
         icon: <Printer className="w-4 h-4" />,
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
         onClick: () => setShowReprint(true),
+        disabled: !hasPermission('archives:reprint'),
+        title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined,
       },
       },
       {
       {
         label: 'Schedule',
         label: 'Schedule',
         icon: <Calendar className="w-4 h-4" />,
         icon: <Calendar className="w-4 h-4" />,
         onClick: () => setShowSchedule(true),
         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',
         label: 'Open in Bambu Studio',
@@ -333,7 +339,8 @@ function ArchiveCard({
       label: 'Scan for Timelapse',
       label: 'Scan for Timelapse',
       icon: <ScanSearch className="w-4 h-4" />,
       icon: <ScanSearch className="w-4 h-4" />,
       onClick: () => timelapseScanMutation.mutate(),
       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: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -349,22 +356,30 @@ function ArchiveCard({
           source3mfInputRef.current?.click();
           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 ? [{
     ...(archive.source_3mf_path ? [{
       label: 'Replace Source 3MF',
       label: 'Replace Source 3MF',
       icon: <Upload className="w-4 h-4" />,
       icon: <Upload className="w-4 h-4" />,
       onClick: () => source3mfInputRef.current?.click(),
       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',
       label: 'Remove Source 3MF',
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteSource3mfConfirm(true),
       onClick: () => setShowDeleteSource3mfConfirm(true),
       danger: 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',
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
       onClick: () => f3dInputRef.current?.click(),
       onClick: () => f3dInputRef.current?.click(),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     ...(archive.f3d_path ? [{
     ...(archive.f3d_path ? [{
       label: 'Download F3D',
       label: 'Download F3D',
@@ -381,6 +396,8 @@ function ArchiveCard({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteF3dConfirm(true),
       onClick: () => setShowDeleteF3dConfirm(true),
       danger: true,
       danger: true,
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     }] : []),
     }] : []),
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -392,6 +409,8 @@ function ArchiveCard({
         link.download = `${archive.print_name || archive.filename}.3mf`;
         link.download = `${archive.print_name || archive.filename}.3mf`;
         link.click();
         link.click();
       },
       },
+      disabled: !hasPermission('archives:read'),
+      title: !hasPermission('archives:read') ? 'You do not have permission to download archives' : undefined,
     },
     },
     {
     {
       label: 'Copy Download Link',
       label: 'Copy Download Link',
@@ -404,6 +423,8 @@ function ArchiveCard({
           showToast('Failed to copy link', 'error');
           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',
       label: 'QR Code',
@@ -426,11 +447,15 @@ function ArchiveCard({
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       onClick: () => favoriteMutation.mutate(),
       onClick: () => favoriteMutation.mutate(),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     {
     {
       label: 'Edit',
       label: 'Edit',
       icon: <Pencil className="w-4 h-4" />,
       icon: <Pencil className="w-4 h-4" />,
       onClick: () => setShowEdit(true),
       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 ? [{
     ...(archive.project_id && archive.project_name ? [{
       label: `Go to Project: ${archive.project_name}`,
       label: `Go to Project: ${archive.project_name}`,
@@ -441,6 +466,8 @@ function ArchiveCard({
       label: 'Add to Project',
       label: 'Add to Project',
       icon: <FolderKanban className="w-4 h-4" />,
       icon: <FolderKanban className="w-4 h-4" />,
       onClick: () => {},
       onClick: () => {},
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
       submenu: (() => {
       submenu: (() => {
         const items: ContextMenuItem[] = [];
         const items: ContextMenuItem[] = [];
 
 
@@ -450,6 +477,7 @@ function ArchiveCard({
             label: 'Remove from Project',
             label: 'Remove from Project',
             icon: <X className="w-4 h-4" />,
             icon: <X className="w-4 h-4" />,
             onClick: () => assignProjectMutation.mutate(null),
             onClick: () => assignProjectMutation.mutate(null),
+            disabled: !hasPermission('archives:update'),
           });
           });
         }
         }
 
 
@@ -476,7 +504,7 @@ function ArchiveCard({
                 label: p.name,
                 label: p.name,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
                 icon: <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: p.color || '#888' }} />,
                 onClick: () => assignProjectMutation.mutate(p.id),
                 onClick: () => assignProjectMutation.mutate(p.id),
-                disabled: archive.project_id === p.id,
+                disabled: archive.project_id === p.id || !hasPermission('archives:update'),
               });
               });
             });
             });
           }
           }
@@ -496,6 +524,8 @@ function ArchiveCard({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteConfirm(true),
       onClick: () => setShowDeleteConfirm(true),
       danger: true,
       danger: true,
+      disabled: !hasPermission('archives:delete'),
+      title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined,
     },
     },
   ];
   ];
 
 
@@ -615,15 +645,22 @@ function ArchiveCard({
         </button>
         </button>
         {/* Favorite star */}
         {/* Favorite star */}
         <button
         <button
-          className="absolute top-2 right-2 p-1 rounded bg-black/50 hover:bg-black/70 transition-colors"
+          className={`absolute top-2 right-2 p-1 rounded transition-colors ${
+            hasPermission('archives:update')
+              ? 'bg-black/50 hover:bg-black/70'
+              : 'bg-black/30 cursor-not-allowed'
+          }`}
           onClick={(e) => {
           onClick={(e) => {
             e.stopPropagation();
             e.stopPropagation();
-            favoriteMutation.mutate();
+            if (hasPermission('archives:update')) {
+              favoriteMutation.mutate();
+            }
           }}
           }}
-          title={archive.is_favorite ? 'Remove from favorites' : 'Add to favorites'}
+          disabled={!hasPermission('archives:update')}
+          title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : (archive.is_favorite ? 'Remove from favorites' : 'Add to favorites')}
         >
         >
           <Star
           <Star
-            className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'}`}
+            className={`w-5 h-5 ${archive.is_favorite ? 'text-yellow-400 fill-yellow-400' : 'text-white'} ${!hasPermission('archives:update') ? 'opacity-50' : ''}`}
           />
           />
         </button>
         </button>
         {(archive.status === 'failed' || archive.status === 'aborted') && (
         {(archive.status === 'failed' || archive.status === 'aborted') && (
@@ -863,6 +900,8 @@ function ArchiveCard({
                 size="sm"
                 size="sm"
                 className="flex-1 min-w-0"
                 className="flex-1 min-w-0"
                 onClick={() => setShowReprint(true)}
                 onClick={() => setShowReprint(true)}
+                disabled={!hasPermission('archives:reprint')}
+                title={!hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined}
               >
               >
                 <Printer className="w-3 h-3 flex-shrink-0" />
                 <Printer className="w-3 h-3 flex-shrink-0" />
                 <span className="hidden sm:inline">Reprint</span>
                 <span className="hidden sm:inline">Reprint</span>
@@ -945,7 +984,8 @@ function ArchiveCard({
             size="sm"
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             className="min-w-0 p-1 sm:p-1.5"
             onClick={() => setShowEdit(true)}
             onClick={() => setShowEdit(true)}
-            title="Edit"
+            disabled={!hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'}
           >
           >
             <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
             <Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
           </Button>
           </Button>
@@ -954,7 +994,8 @@ function ArchiveCard({
             size="sm"
             size="sm"
             className="min-w-0 p-1 sm:p-1.5"
             className="min-w-0 p-1 sm:p-1.5"
             onClick={() => setShowDeleteConfirm(true)}
             onClick={() => setShowDeleteConfirm(true)}
-            title="Delete"
+            disabled={!hasPermission('archives:delete')}
+            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'}
           >
           >
             <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
             <Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
           </Button>
           </Button>
@@ -1211,6 +1252,7 @@ function ArchiveListRow({
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [showEdit, setShowEdit] = useState(false);
   const [showEdit, setShowEdit] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
   const [showReprint, setShowReprint] = useState(false);
@@ -1355,11 +1397,15 @@ function ArchiveListRow({
         label: 'Print',
         label: 'Print',
         icon: <Printer className="w-4 h-4" />,
         icon: <Printer className="w-4 h-4" />,
         onClick: () => setShowReprint(true),
         onClick: () => setShowReprint(true),
+        disabled: !hasPermission('archives:reprint'),
+        title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined,
       },
       },
       {
       {
         label: 'Schedule',
         label: 'Schedule',
         icon: <Calendar className="w-4 h-4" />,
         icon: <Calendar className="w-4 h-4" />,
         onClick: () => setShowSchedule(true),
         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',
         label: 'Open in Bambu Studio',
@@ -1406,7 +1452,8 @@ function ArchiveListRow({
       label: 'Scan for Timelapse',
       label: 'Scan for Timelapse',
       icon: <ScanSearch className="w-4 h-4" />,
       icon: <ScanSearch className="w-4 h-4" />,
       onClick: () => timelapseScanMutation.mutate(),
       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: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -1422,22 +1469,30 @@ function ArchiveListRow({
           source3mfInputRef.current?.click();
           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 ? [{
     ...(archive.source_3mf_path ? [{
       label: 'Replace Source 3MF',
       label: 'Replace Source 3MF',
       icon: <Upload className="w-4 h-4" />,
       icon: <Upload className="w-4 h-4" />,
       onClick: () => source3mfInputRef.current?.click(),
       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',
       label: 'Remove Source 3MF',
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteSource3mfConfirm(true),
       onClick: () => setShowDeleteSource3mfConfirm(true),
       danger: 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',
       label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D',
       icon: <Box className="w-4 h-4" />,
       icon: <Box className="w-4 h-4" />,
       onClick: () => f3dInputRef.current?.click(),
       onClick: () => f3dInputRef.current?.click(),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     ...(archive.f3d_path ? [{
     ...(archive.f3d_path ? [{
       label: 'Download F3D',
       label: 'Download F3D',
@@ -1454,6 +1509,8 @@ function ArchiveListRow({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteF3dConfirm(true),
       onClick: () => setShowDeleteF3dConfirm(true),
       danger: true,
       danger: true,
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     }] : []),
     }] : []),
     { label: '', divider: true, onClick: () => {} },
     { label: '', divider: true, onClick: () => {} },
     {
     {
@@ -1465,6 +1522,8 @@ function ArchiveListRow({
         link.download = `${archive.print_name || archive.filename}.3mf`;
         link.download = `${archive.print_name || archive.filename}.3mf`;
         link.click();
         link.click();
       },
       },
+      disabled: !hasPermission('archives:read'),
+      title: !hasPermission('archives:read') ? 'You do not have permission to download archives' : undefined,
     },
     },
     {
     {
       label: 'Copy Download Link',
       label: 'Copy Download Link',
@@ -1477,6 +1536,8 @@ function ArchiveListRow({
           showToast('Failed to copy link', 'error');
           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',
       label: 'QR Code',
@@ -1499,11 +1560,15 @@ function ArchiveListRow({
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       icon: <Star className={`w-4 h-4 ${archive.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />,
       onClick: () => favoriteMutation.mutate(),
       onClick: () => favoriteMutation.mutate(),
+      disabled: !hasPermission('archives:update'),
+      title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined,
     },
     },
     {
     {
       label: 'Edit',
       label: 'Edit',
       icon: <Pencil className="w-4 h-4" />,
       icon: <Pencil className="w-4 h-4" />,
       onClick: () => setShowEdit(true),
       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 ? [{
     ...(archive.project_id && archive.project_name ? [{
       label: `Go to Project: ${archive.project_name}`,
       label: `Go to Project: ${archive.project_name}`,
@@ -1564,6 +1629,8 @@ function ArchiveListRow({
       icon: <Trash2 className="w-4 h-4" />,
       icon: <Trash2 className="w-4 h-4" />,
       onClick: () => setShowDeleteConfirm(true),
       onClick: () => setShowDeleteConfirm(true),
       danger: true,
       danger: true,
+      disabled: !hasPermission('archives:delete'),
+      title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined,
     },
     },
   ];
   ];
 
 
@@ -1696,7 +1763,8 @@ function ArchiveListRow({
             variant="ghost"
             variant="ghost"
             size="sm"
             size="sm"
             onClick={() => setShowEdit(true)}
             onClick={() => setShowEdit(true)}
-            title="Edit"
+            disabled={!hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'}
           >
           >
             <Pencil className="w-4 h-4" />
             <Pencil className="w-4 h-4" />
           </Button>
           </Button>
@@ -1704,7 +1772,8 @@ function ArchiveListRow({
             variant="ghost"
             variant="ghost"
             size="sm"
             size="sm"
             onClick={() => setShowDeleteConfirm(true)}
             onClick={() => setShowDeleteConfirm(true)}
-            title="Delete"
+            disabled={!hasPermission('archives:delete')}
+            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'}
           >
           >
             <Trash2 className="w-4 h-4 text-red-400" />
             <Trash2 className="w-4 h-4 text-red-400" />
           </Button>
           </Button>
@@ -1958,6 +2027,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
 export function ArchivesPage() {
 export function ArchivesPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const searchInputRef = useRef<HTMLInputElement>(null);
   const searchInputRef = useRef<HTMLInputElement>(null);
   const [search, setSearch] = useState('');
   const [search, setSearch] = useState('');
   const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
   const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
@@ -2372,6 +2442,8 @@ export function ArchivesPage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowBatchTag(true)}
             onClick={() => setShowBatchTag(true)}
+            disabled={!hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
           >
           >
             <Tag className="w-4 h-4" />
             <Tag className="w-4 h-4" />
             Tags
             Tags
@@ -2380,6 +2452,8 @@ export function ArchivesPage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowBatchProject(true)}
             onClick={() => setShowBatchProject(true)}
+            disabled={!hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
           >
           >
             <FolderKanban className="w-4 h-4" />
             <FolderKanban className="w-4 h-4" />
             Project
             Project
@@ -2387,6 +2461,8 @@ export function ArchivesPage() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
+            disabled={!hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
             onClick={() => {
             onClick={() => {
               const ids = Array.from(selectedIds);
               const ids = Array.from(selectedIds);
               Promise.all(ids.map(id => api.toggleFavorite(id)))
               Promise.all(ids.map(id => api.toggleFavorite(id)))
@@ -2406,6 +2482,8 @@ export function ArchivesPage() {
             size="sm"
             size="sm"
             className="bg-red-500 hover:bg-red-600"
             className="bg-red-500 hover:bg-red-600"
             onClick={() => setShowBulkDeleteConfirm(true)}
             onClick={() => setShowBulkDeleteConfirm(true)}
+            disabled={!hasPermission('archives:delete')}
+            title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined}
           >
           >
             <Trash2 className="w-4 h-4" />
             <Trash2 className="w-4 h-4" />
             Delete
             Delete
@@ -2527,7 +2605,11 @@ export function ArchivesPage() {
               Select
               Select
             </Button>
             </Button>
           )}
           )}
-          <Button onClick={() => setShowUpload(true)}>
+          <Button
+            onClick={() => setShowUpload(true)}
+            disabled={!hasPermission('archives:create')}
+            title={!hasPermission('archives:create') ? 'You do not have permission to create archives' : undefined}
+          >
             <Upload className="w-4 h-4" />
             <Upload className="w-4 h-4" />
             Upload 3MF
             Upload 3MF
           </Button>
           </Button>

+ 138 - 46
frontend/src/pages/FileManagerPage.tsx

@@ -45,12 +45,14 @@ import type {
   LibraryFolderUpdate,
   LibraryFolderUpdate,
   AppSettings,
   AppSettings,
   Archive,
   Archive,
+  Permission,
 } from '../api/client';
 } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
+import { useAuth } from '../contexts/AuthContext';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
@@ -724,9 +726,10 @@ interface FolderTreeItemProps {
   onRename: (folder: LibraryFolderTree) => void;
   onRename: (folder: LibraryFolderTree) => void;
   depth?: number;
   depth?: number;
   wrapNames?: boolean;
   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 [expanded, setExpanded] = useState(true);
   const [showActions, setShowActions] = useState(false);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
   const hasChildren = folder.children.length > 0;
@@ -799,22 +802,34 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
                 <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
                 <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
                 <div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                 <div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onRename(folder); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:update')) { onRename(folder); setShowActions(false); } }}
+                  disabled={!hasPermission('library:update')}
+                  title={!hasPermission('library:update') ? 'You do not have permission to rename folders' : undefined}
                 >
                 >
                   <Pencil className="w-3.5 h-3.5" />
                   <Pencil className="w-3.5 h-3.5" />
                   Rename
                   Rename
                 </button>
                 </button>
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onLink(folder); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:update')) { onLink(folder); setShowActions(false); } }}
+                  disabled={!hasPermission('library:update')}
+                  title={!hasPermission('library:update') ? 'You do not have permission to link folders' : undefined}
                 >
                 >
                   <Link2 className="w-3.5 h-3.5" />
                   <Link2 className="w-3.5 h-3.5" />
                   {isLinked ? 'Change Link...' : 'Link to...'}
                   {isLinked ? 'Change Link...' : 'Link to...'}
                 </button>
                 </button>
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onDelete(folder.id); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:delete')) { onDelete(folder.id); setShowActions(false); } }}
+                  disabled={!hasPermission('library:delete')}
+                  title={!hasPermission('library:delete') ? 'You do not have permission to delete folders' : undefined}
                 >
                 >
                   <Trash2 className="w-3.5 h-3.5" />
                   <Trash2 className="w-3.5 h-3.5" />
                   Delete
                   Delete
@@ -838,6 +853,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               onRename={onRename}
               onRename={onRename}
               depth={depth + 1}
               depth={depth + 1}
               wrapNames={wrapNames}
               wrapNames={wrapNames}
+              hasPermission={hasPermission}
             />
             />
           ))}
           ))}
         </div>
         </div>
@@ -865,9 +881,10 @@ interface FileCardProps {
   onRename?: (file: LibraryFileListItem) => void;
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   thumbnailVersion?: number;
   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);
   const [showActions, setShowActions] = useState(false);
 
 
   return (
   return (
@@ -936,8 +953,12 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             <div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
             <div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
               {onPrint && isSlicedFilename(file.filename) && (
               {onPrint && isSlicedFilename(file.filename) && (
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-bambu-green hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onPrint(file); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('printers:control') ? 'text-bambu-green hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('printers:control')) { onPrint(file); setShowActions(false); } }}
+                  disabled={!hasPermission('printers:control')}
+                  title={!hasPermission('printers:control') ? 'You do not have permission to print' : undefined}
                 >
                 >
                   <Printer className="w-3.5 h-3.5" />
                   <Printer className="w-3.5 h-3.5" />
                   Print
                   Print
@@ -945,24 +966,36 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               )}
               )}
               {onAddToQueue && isSlicedFilename(file.filename) && (
               {onAddToQueue && isSlicedFilename(file.filename) && (
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('queue:create') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('queue:create')) { onAddToQueue(file.id); setShowActions(false); } }}
+                  disabled={!hasPermission('queue:create')}
+                  title={!hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined}
                 >
                 >
                   <Clock className="w-3.5 h-3.5" />
                   <Clock className="w-3.5 h-3.5" />
                   Add to Queue
                   Add to Queue
                 </button>
                 </button>
               )}
               )}
               <button
               <button
-                className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                onClick={() => { onDownload(file.id); setShowActions(false); }}
+                className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                  hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                }`}
+                onClick={() => { if (hasPermission('library:read')) { onDownload(file.id); setShowActions(false); } }}
+                disabled={!hasPermission('library:read')}
+                title={!hasPermission('library:read') ? 'You do not have permission to download files' : undefined}
               >
               >
                 <Download className="w-3.5 h-3.5" />
                 <Download className="w-3.5 h-3.5" />
                 Download
                 Download
               </button>
               </button>
               {onRename && (
               {onRename && (
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onRename(file); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:update')) { onRename(file); setShowActions(false); } }}
+                  disabled={!hasPermission('library:update')}
+                  title={!hasPermission('library:update') ? 'You do not have permission to rename files' : undefined}
                 >
                 >
                   <Pencil className="w-3.5 h-3.5" />
                   <Pencil className="w-3.5 h-3.5" />
                   Rename
                   Rename
@@ -970,16 +1003,24 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               )}
               )}
               {onGenerateThumbnail && file.file_type === 'stl' && (
               {onGenerateThumbnail && file.file_type === 'stl' && (
                 <button
                 <button
-                  className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                  onClick={() => { onGenerateThumbnail(file); setShowActions(false); }}
+                  className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                    hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                  }`}
+                  onClick={() => { if (hasPermission('library:update')) { onGenerateThumbnail(file); setShowActions(false); } }}
+                  disabled={!hasPermission('library:update')}
+                  title={!hasPermission('library:update') ? 'You do not have permission to generate thumbnails' : undefined}
                 >
                 >
                   <Image className="w-3.5 h-3.5" />
                   <Image className="w-3.5 h-3.5" />
                   Generate Thumbnail
                   Generate Thumbnail
                 </button>
                 </button>
               )}
               )}
               <button
               <button
-                className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
-                onClick={() => { onDelete(file.id); setShowActions(false); }}
+                className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
+                  hasPermission('library:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                }`}
+                onClick={() => { if (hasPermission('library:delete')) { onDelete(file.id); setShowActions(false); } }}
+                disabled={!hasPermission('library:delete')}
+                title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined}
               >
               >
                 <Trash2 className="w-3.5 h-3.5" />
                 <Trash2 className="w-3.5 h-3.5" />
                 Delete
                 Delete
@@ -1004,6 +1045,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
 export function FileManagerPage() {
 export function FileManagerPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [searchParams] = useSearchParams();
   const [searchParams] = useSearchParams();
 
 
   // Read folder ID from URL query parameter
   // Read folder ID from URL query parameter
@@ -1470,8 +1512,8 @@ export function FileManagerPage() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={() => batchThumbnailMutation.mutate()}
             onClick={() => batchThumbnailMutation.mutate()}
-            disabled={batchThumbnailMutation.isPending}
-            title="Generate thumbnails for STL files missing them"
+            disabled={batchThumbnailMutation.isPending || !hasPermission('library:update')}
+            title={!hasPermission('library:update') ? 'You do not have permission to generate thumbnails' : 'Generate thumbnails for STL files missing them'}
           >
           >
             {batchThumbnailMutation.isPending ? (
             {batchThumbnailMutation.isPending ? (
               <Loader2 className="w-4 h-4 mr-2 animate-spin" />
               <Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -1480,11 +1522,20 @@ export function FileManagerPage() {
             )}
             )}
             Generate Thumbnails
             Generate Thumbnails
           </Button>
           </Button>
-          <Button variant="secondary" onClick={() => setShowNewFolderModal(true)}>
+          <Button
+            variant="secondary"
+            onClick={() => setShowNewFolderModal(true)}
+            disabled={!hasPermission('library:upload')}
+            title={!hasPermission('library:upload') ? 'You do not have permission to create folders' : undefined}
+          >
             <FolderPlus className="w-4 h-4 mr-2" />
             <FolderPlus className="w-4 h-4 mr-2" />
             New Folder
             New Folder
           </Button>
           </Button>
-          <Button onClick={() => setShowUploadModal(true)}>
+          <Button
+            onClick={() => setShowUploadModal(true)}
+            disabled={!hasPermission('library:upload')}
+            title={!hasPermission('library:upload') ? 'You do not have permission to upload files' : undefined}
+          >
             <Upload className="w-4 h-4 mr-2" />
             <Upload className="w-4 h-4 mr-2" />
             Upload
             Upload
           </Button>
           </Button>
@@ -1634,6 +1685,7 @@ export function FileManagerPage() {
                 onLink={setLinkFolder}
                 onLink={setLinkFolder}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 wrapNames={wrapFolderNames}
                 wrapNames={wrapFolderNames}
+                hasPermission={hasPermission}
               />
               />
             ))}
             ))}
           </div>
           </div>
@@ -1752,6 +1804,8 @@ export function FileManagerPage() {
                         variant="primary"
                         variant="primary"
                         size="sm"
                         size="sm"
                         onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
                         onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
+                        disabled={!hasPermission('printers:control')}
+                        title={!hasPermission('printers:control') ? 'You do not have permission to print' : undefined}
                       >
                       >
                         <Play className="w-4 h-4 sm:mr-1" />
                         <Play className="w-4 h-4 sm:mr-1" />
                         <span className="hidden sm:inline">Print</span>
                         <span className="hidden sm:inline">Print</span>
@@ -1762,7 +1816,8 @@ export function FileManagerPage() {
                         variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
                         variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
                         size="sm"
                         size="sm"
                         onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
                         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}
                       >
                       >
                         <Clock className="w-4 h-4 sm:mr-1" />
                         <Clock className="w-4 h-4 sm:mr-1" />
                         <span className="hidden sm:inline">{addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}</span>
                         <span className="hidden sm:inline">{addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}</span>
@@ -1772,6 +1827,8 @@ export function FileManagerPage() {
                       variant="secondary"
                       variant="secondary"
                       size="sm"
                       size="sm"
                       onClick={() => setShowMoveModal(true)}
                       onClick={() => setShowMoveModal(true)}
+                      disabled={!hasPermission('library:update')}
+                      title={!hasPermission('library:update') ? 'You do not have permission to move files' : undefined}
                     >
                     >
                       <MoveRight className="w-4 h-4 sm:mr-1" />
                       <MoveRight className="w-4 h-4 sm:mr-1" />
                       <span className="hidden sm:inline">Move</span>
                       <span className="hidden sm:inline">Move</span>
@@ -1786,6 +1843,8 @@ export function FileManagerPage() {
                           setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
                           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}
                     >
                     >
                       <Trash2 className="w-4 h-4 sm:mr-1" />
                       <Trash2 className="w-4 h-4 sm:mr-1" />
                       <span className="hidden sm:inline">Delete</span>
                       <span className="hidden sm:inline">Delete</span>
@@ -1825,7 +1884,11 @@ export function FileManagerPage() {
                   ? 'Upload files or move files into this folder to get started.'
                   ? 'Upload files or move files into this folder to get started.'
                   : 'Upload files to start organizing your print-related files.'}
                   : 'Upload files to start organizing your print-related files.'}
               </p>
               </p>
-              <Button onClick={() => setShowUploadModal(true)}>
+              <Button
+                onClick={() => setShowUploadModal(true)}
+                disabled={!hasPermission('library:upload')}
+                title={!hasPermission('library:upload') ? 'You do not have permission to upload files' : undefined}
+              >
                 <Plus className="w-4 h-4 mr-2" />
                 <Plus className="w-4 h-4 mr-2" />
                 Upload Files
                 Upload Files
               </Button>
               </Button>
@@ -1860,6 +1923,7 @@ export function FileManagerPage() {
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     thumbnailVersion={thumbnailVersions[file.id]}
                     thumbnailVersion={thumbnailVersions[file.id]}
+                    hasPermission={hasPermission}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -1946,50 +2010,78 @@ export function FileManagerPage() {
                       {isSlicedFilename(file.filename) && (
                       {isSlicedFilename(file.filename) && (
                         <>
                         <>
                           <button
                           <button
-                            onClick={() => setPrintFile(file)}
-                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
-                            title="Print"
+                            onClick={() => hasPermission('printers:control') && setPrintFile(file)}
+                            className={`p-1.5 rounded transition-colors ${
+                              hasPermission('printers:control')
+                                ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                                : 'text-bambu-gray/50 cursor-not-allowed'
+                            }`}
+                            title={hasPermission('printers:control') ? 'Print' : 'You do not have permission to print'}
+                            disabled={!hasPermission('printers:control')}
                           >
                           >
                             <Printer className="w-4 h-4" />
                             <Printer className="w-4 h-4" />
                           </button>
                           </button>
                           <button
                           <button
-                            onClick={() => addToQueueMutation.mutate([file.id])}
-                            className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                            title="Add to Queue"
-                            disabled={addToQueueMutation.isPending}
+                            onClick={() => hasPermission('queue:create') && addToQueueMutation.mutate([file.id])}
+                            className={`p-1.5 rounded transition-colors ${
+                              hasPermission('queue:create')
+                                ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
+                                : 'text-bambu-gray/50 cursor-not-allowed'
+                            }`}
+                            title={hasPermission('queue:create') ? 'Add to Queue' : 'You do not have permission to add to queue'}
+                            disabled={addToQueueMutation.isPending || !hasPermission('queue:create')}
                           >
                           >
                             <Clock className="w-4 h-4" />
                             <Clock className="w-4 h-4" />
                           </button>
                           </button>
                         </>
                         </>
                       )}
                       )}
                       <button
                       <button
-                        onClick={() => handleDownload(file.id)}
-                        className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                        title="Download"
+                        onClick={() => hasPermission('library:read') && handleDownload(file.id)}
+                        className={`p-1.5 rounded transition-colors ${
+                          hasPermission('library:read')
+                            ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
+                            : 'text-bambu-gray/50 cursor-not-allowed'
+                        }`}
+                        title={hasPermission('library:read') ? 'Download' : 'You do not have permission to download files'}
+                        disabled={!hasPermission('library:read')}
                       >
                       >
                         <Download className="w-4 h-4" />
                         <Download className="w-4 h-4" />
                       </button>
                       </button>
                       <button
                       <button
-                        onClick={() => setRenameItem({ type: 'file', id: file.id, name: file.filename })}
-                        className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
-                        title="Rename"
+                        onClick={() => hasPermission('library:update') && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
+                        className={`p-1.5 rounded transition-colors ${
+                          hasPermission('library:update')
+                            ? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
+                            : 'text-bambu-gray/50 cursor-not-allowed'
+                        }`}
+                        title={hasPermission('library:update') ? 'Rename' : 'You do not have permission to rename files'}
+                        disabled={!hasPermission('library:update')}
                       >
                       >
                         <Pencil className="w-4 h-4" />
                         <Pencil className="w-4 h-4" />
                       </button>
                       </button>
                       {file.file_type === 'stl' && (
                       {file.file_type === 'stl' && (
                         <button
                         <button
-                          onClick={() => singleThumbnailMutation.mutate(file.id)}
-                          className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
-                          title="Generate Thumbnail"
-                          disabled={singleThumbnailMutation.isPending}
+                          onClick={() => hasPermission('library:update') && singleThumbnailMutation.mutate(file.id)}
+                          className={`p-1.5 rounded transition-colors ${
+                            hasPermission('library:update')
+                              ? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
+                              : 'text-bambu-gray/50 cursor-not-allowed'
+                          }`}
+                          title={hasPermission('library:update') ? 'Generate Thumbnail' : 'You do not have permission to generate thumbnails'}
+                          disabled={singleThumbnailMutation.isPending || !hasPermission('library:update')}
                         >
                         >
                           <Image className="w-4 h-4" />
                           <Image className="w-4 h-4" />
                         </button>
                         </button>
                       )}
                       )}
                       <button
                       <button
-                        onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
-                        className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
-                        title="Delete"
+                        onClick={() => hasPermission('library:delete') && setDeleteConfirm({ type: 'file', id: file.id })}
+                        className={`p-1.5 rounded transition-colors ${
+                          hasPermission('library:delete')
+                            ? 'hover:bg-bambu-dark text-bambu-gray hover:text-red-400'
+                            : 'text-bambu-gray/50 cursor-not-allowed'
+                        }`}
+                        title={hasPermission('library:delete') ? 'Delete' : 'You do not have permission to delete files'}
+                        disabled={!hasPermission('library:delete')}
                       >
                       >
                         <Trash2 className="w-4 h-4" />
                         <Trash2 className="w-4 h-4" />
                       </button>
                       </button>

+ 496 - 0
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<Group | null>(null);
+  const [deleteGroupId, setDeleteGroupId] = useState<number | null>(null);
+  const [formData, setFormData] = useState<{
+    name: string;
+    description: string;
+    permissions: Permission[];
+  }>({
+    name: '',
+    description: '',
+    permissions: [],
+  });
+  const [expandedCategories, setExpandedCategories] = useState<Set<string>>(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<string>();
+    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 (
+      <div className="p-6">
+        <Card>
+          <CardContent className="py-6">
+            <div className="flex items-center gap-3 text-red-400">
+              <Shield className="w-5 h-5" />
+              <p className="text-white">You do not have permission to access this page.</p>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+    );
+  }
+
+  const renderPermissionEditor = () => (
+    <div className="space-y-2 max-h-96 overflow-y-auto">
+      {permissionsData?.categories.map((category) => (
+        <div key={category.name} className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
+          <div
+            className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary cursor-pointer hover:bg-bambu-dark-tertiary transition-colors"
+            onClick={() => toggleCategory(category.name)}
+          >
+            <div className="flex items-center gap-3">
+              <button
+                type="button"
+                onClick={(e) => {
+                  e.stopPropagation();
+                  toggleCategoryPermissions(category, !isCategoryFullySelected(category));
+                }}
+                className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${
+                  isCategoryFullySelected(category)
+                    ? 'bg-bambu-green border-bambu-green'
+                    : isCategoryPartiallySelected(category)
+                    ? 'bg-bambu-green/50 border-bambu-green'
+                    : 'border-bambu-gray hover:border-white'
+                }`}
+              >
+                {(isCategoryFullySelected(category) || isCategoryPartiallySelected(category)) && (
+                  <Check className="w-3 h-3 text-white" />
+                )}
+              </button>
+              <span className="text-white font-medium">{category.name}</span>
+              <span className="text-xs text-bambu-gray">
+                ({category.permissions.filter((p) => formData.permissions.includes(p.value)).length}/
+                {category.permissions.length})
+              </span>
+            </div>
+            {expandedCategories.has(category.name) ? (
+              <ChevronDown className="w-4 h-4 text-bambu-gray" />
+            ) : (
+              <ChevronRight className="w-4 h-4 text-bambu-gray" />
+            )}
+          </div>
+          {expandedCategories.has(category.name) && (
+            <div className="p-3 bg-bambu-dark space-y-2">
+              {category.permissions.map((perm) => (
+                <label
+                  key={perm.value}
+                  className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer"
+                >
+                  <input
+                    type="checkbox"
+                    checked={formData.permissions.includes(perm.value)}
+                    onChange={() => togglePermission(perm.value)}
+                    className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
+                  />
+                  <span className="text-sm text-bambu-gray">{perm.label}</span>
+                </label>
+              ))}
+            </div>
+          )}
+        </div>
+      ))}
+    </div>
+  );
+
+  return (
+    <div className="p-6">
+      <div className="flex justify-between items-center mb-6">
+        <div className="flex items-center gap-4">
+          <button
+            onClick={() => navigate('/settings?tab=users')}
+            className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+            title="Back to Settings"
+          >
+            <ArrowLeft className="w-5 h-5" />
+          </button>
+          <div>
+            <h1 className="text-2xl font-bold text-white flex items-center gap-2">
+              <Shield className="w-6 h-6 text-bambu-green" />
+              Group Management
+            </h1>
+            <p className="text-sm text-bambu-gray mt-1">
+              Manage permission groups for access control
+            </p>
+          </div>
+        </div>
+        {hasPermission('groups:create') && (
+          <Button
+            onClick={() => {
+              setShowCreateModal(true);
+              resetForm();
+            }}
+          >
+            <Plus className="w-4 h-4" />
+            Create Group
+          </Button>
+        )}
+      </div>
+
+      {groupsLoading ? (
+        <div className="flex items-center justify-center py-12">
+          <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+        </div>
+      ) : (
+        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
+          {groups.map((group) => (
+            <Card key={group.id}>
+              <CardHeader>
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-2">
+                    <Shield
+                      className={`w-5 h-5 ${
+                        group.name === 'Administrators'
+                          ? 'text-purple-400'
+                          : group.name === 'Operators'
+                          ? 'text-blue-400'
+                          : group.name === 'Viewers'
+                          ? 'text-green-400'
+                          : 'text-bambu-gray'
+                      }`}
+                    />
+                    <h3 className="text-lg font-semibold text-white">{group.name}</h3>
+                    {group.is_system && (
+                      <span className="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
+                        System
+                      </span>
+                    )}
+                  </div>
+                </div>
+              </CardHeader>
+              <CardContent>
+                <p className="text-sm text-bambu-gray mb-4">{group.description || 'No description'}</p>
+                <div className="flex items-center justify-between">
+                  <div className="flex items-center gap-2 text-sm text-bambu-gray">
+                    <Users className="w-4 h-4" />
+                    <span>{group.user_count} users</span>
+                  </div>
+                  <div className="text-xs text-bambu-gray">
+                    {group.permissions.length} permissions
+                  </div>
+                </div>
+                <div className="flex gap-2 mt-4 pt-4 border-t border-bambu-dark-tertiary">
+                  {hasPermission('groups:update') && (
+                    <Button size="sm" variant="ghost" onClick={() => startEdit(group)}>
+                      <Edit2 className="w-4 h-4" />
+                      Edit
+                    </Button>
+                  )}
+                  {hasPermission('groups:delete') && !group.is_system && (
+                    <Button size="sm" variant="ghost" onClick={() => handleDelete(group.id)}>
+                      <Trash2 className="w-4 h-4" />
+                      Delete
+                    </Button>
+                  )}
+                </div>
+              </CardContent>
+            </Card>
+          ))}
+        </div>
+      )}
+
+      {/* Create/Edit Group Modal */}
+      {(showCreateModal || editingGroup) && (
+        <div
+          className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+          onClick={() => {
+            setShowCreateModal(false);
+            setEditingGroup(null);
+            resetForm();
+          }}
+        >
+          <Card
+            className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
+            onClick={(e: React.MouseEvent) => e.stopPropagation()}
+          >
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Shield className="w-5 h-5 text-bambu-green" />
+                  <h2 className="text-lg font-semibold text-white">
+                    {editingGroup ? 'Edit Group' : 'Create Group'}
+                  </h2>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={() => {
+                    setShowCreateModal(false);
+                    setEditingGroup(null);
+                    resetForm();
+                  }}
+                >
+                  <X className="w-5 h-5" />
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div className="space-y-4">
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Group Name
+                  </label>
+                  <input
+                    type="text"
+                    value={formData.name}
+                    onChange={(e) => 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 && (
+                    <p className="text-xs text-yellow-400 mt-1">System group names cannot be changed</p>
+                  )}
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Description
+                  </label>
+                  <textarea
+                    value={formData.description}
+                    onChange={(e) => setFormData({ ...formData, description: e.target.value })}
+                    rows={2}
+                    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 resize-none"
+                    placeholder="Enter description (optional)"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Permissions ({formData.permissions.length} selected)
+                  </label>
+                  {renderPermissionEditor()}
+                </div>
+              </div>
+              <div className="mt-6 flex justify-end gap-3">
+                <Button
+                  variant="secondary"
+                  onClick={() => {
+                    setShowCreateModal(false);
+                    setEditingGroup(null);
+                    resetForm();
+                  }}
+                >
+                  Cancel
+                </Button>
+                <Button
+                  onClick={editingGroup ? handleUpdate : handleCreate}
+                  disabled={createMutation.isPending || updateMutation.isPending || !formData.name.trim()}
+                >
+                  {(createMutation.isPending || updateMutation.isPending) ? (
+                    <>
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      {editingGroup ? 'Saving...' : 'Creating...'}
+                    </>
+                  ) : (
+                    <>
+                      <Save className="w-4 h-4" />
+                      {editingGroup ? 'Save Changes' : 'Create Group'}
+                    </>
+                  )}
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+
+      {/* Delete Confirmation Modal */}
+      {deleteGroupId !== null && (
+        <ConfirmModal
+          title="Delete Group"
+          message="Are you sure you want to delete this group? Users in this group will lose these permissions."
+          confirmText="Delete Group"
+          variant="danger"
+          onConfirm={() => {
+            deleteMutation.mutate(deleteGroupId);
+            setDeleteGroupId(null);
+          }}
+          onCancel={() => setDeleteGroupId(null)}
+        />
+      )}
+    </div>
+  );
+}

+ 61 - 0
frontend/src/pages/LoginPage.tsx

@@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
+import { HelpCircle, X } from 'lucide-react';
 
 
 export function LoginPage() {
 export function LoginPage() {
   const navigate = useNavigate();
   const navigate = useNavigate();
@@ -12,6 +13,7 @@ export function LoginPage() {
   const { mode } = useTheme();
   const { mode } = useTheme();
   const [username, setUsername] = useState('');
   const [username, setUsername] = useState('');
   const [password, setPassword] = useState('');
   const [password, setPassword] = useState('');
+  const [showForgotPassword, setShowForgotPassword] = useState(false);
 
 
   const loginMutation = useMutation({
   const loginMutation = useMutation({
     mutationFn: () => login(username, password),
     mutationFn: () => login(username, password),
@@ -96,8 +98,67 @@ export function LoginPage() {
               {loginMutation.isPending ? 'Logging in...' : 'Sign in'}
               {loginMutation.isPending ? 'Logging in...' : 'Sign in'}
             </button>
             </button>
           </div>
           </div>
+
+          <div className="text-center">
+            <button
+              type="button"
+              onClick={() => setShowForgotPassword(true)}
+              className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
+            >
+              Forgot your password?
+            </button>
+          </div>
         </form>
         </form>
       </div>
       </div>
+
+      {/* Forgot Password Modal */}
+      {showForgotPassword && (
+        <div
+          className="fixed inset-0 bg-black flex items-center justify-center z-50 p-4"
+          onClick={() => setShowForgotPassword(false)}
+        >
+          <div
+            className="w-full max-w-md bg-bambu-card rounded-xl border border-bambu-dark-tertiary shadow-lg p-6"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <div className="flex items-center justify-between mb-4">
+              <div className="flex items-center gap-2">
+                <HelpCircle className="w-5 h-5 text-bambu-green" />
+                <h2 className="text-lg font-semibold text-white">Forgot Password</h2>
+              </div>
+              <button
+                onClick={() => setShowForgotPassword(false)}
+                className="p-1 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
+              >
+                <X className="w-5 h-5" />
+              </button>
+            </div>
+
+            <div className="space-y-4">
+              <p className="text-bambu-gray">
+                If you've forgotten your password, please contact your system administrator to reset it.
+              </p>
+
+              <div className="bg-bambu-dark rounded-lg p-4 space-y-2">
+                <p className="text-sm text-white font-medium">How to reset your password:</p>
+                <ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
+                  <li>Contact your Bambuddy administrator</li>
+                  <li>Ask them to reset your password in User Management</li>
+                  <li>They can set a new temporary password for you</li>
+                  <li>Log in with the new password and change it in Settings</li>
+                </ol>
+              </div>
+
+              <button
+                onClick={() => setShowForgotPassword(false)}
+                className="w-full py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
+              >
+                Got it
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 47 - 16
frontend/src/pages/MaintenancePage.tsx

@@ -36,11 +36,12 @@ import {
   ExternalLink,
   ExternalLink,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
+import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { Toggle } from '../components/Toggle';
 import { Toggle } from '../components/Toggle';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 
 // Icon mapping for maintenance types
 // Icon mapping for maintenance types
 const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
 const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -199,10 +200,12 @@ function MaintenanceCard({
   item,
   item,
   onPerform,
   onPerform,
   onToggle,
   onToggle,
+  hasPermission,
 }: {
 }: {
   item: MaintenanceStatus;
   item: MaintenanceStatus;
   onPerform: (id: number) => void;
   onPerform: (id: number) => void;
   onToggle: (id: number, enabled: boolean) => void;
   onToggle: (id: number, enabled: boolean) => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
   const Icon = getIcon(item.maintenance_type_icon);
   const Icon = getIcon(item.maintenance_type_icon);
   const intervalType = item.interval_type || 'hours';
   const intervalType = item.interval_type || 'hours';
@@ -324,15 +327,19 @@ function MaintenanceCard({
 
 
         {/* Actions */}
         {/* Actions */}
         <div className="flex items-center gap-2 shrink-0">
         <div className="flex items-center gap-2 shrink-0">
-          <Toggle
-            checked={item.enabled}
-            onChange={(checked) => onToggle(item.id, checked)}
-          />
+          <span title={!hasPermission('maintenance:update') ? 'You do not have permission to update maintenance items' : undefined}>
+            <Toggle
+              checked={item.enabled}
+              onChange={(checked) => onToggle(item.id, checked)}
+              disabled={!hasPermission('maintenance:update')}
+            />
+          </span>
           <Button
           <Button
             size="sm"
             size="sm"
             variant={item.is_due ? 'primary' : 'secondary'}
             variant={item.is_due ? 'primary' : 'secondary'}
             onClick={() => onPerform(item.id)}
             onClick={() => onPerform(item.id)}
-            disabled={!item.enabled}
+            disabled={!item.enabled || !hasPermission('maintenance:update')}
+            title={!hasPermission('maintenance:update') ? 'You do not have permission to perform maintenance' : undefined}
             className="!px-3"
             className="!px-3"
           >
           >
             <RotateCcw className="w-3.5 h-3.5" />
             <RotateCcw className="w-3.5 h-3.5" />
@@ -350,11 +357,13 @@ function PrinterSection({
   onPerform,
   onPerform,
   onToggle,
   onToggle,
   onSetHours,
   onSetHours,
+  hasPermission,
 }: {
 }: {
   overview: PrinterMaintenanceOverview;
   overview: PrinterMaintenanceOverview;
   onPerform: (id: number) => void;
   onPerform: (id: number) => void;
   onToggle: (id: number, enabled: boolean) => void;
   onToggle: (id: number, enabled: boolean) => void;
   onSetHours: (printerId: number, hours: number) => void;
   onSetHours: (printerId: number, hours: number) => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
   const [expanded, setExpanded] = useState(true);
   const [expanded, setExpanded] = useState(true);
   const [editingHours, setEditingHours] = useState(false);
   const [editingHours, setEditingHours] = useState(false);
@@ -445,14 +454,16 @@ function PrinterSection({
             ) : (
             ) : (
               <button
               <button
                 onClick={() => {
                 onClick={() => {
+                  if (!hasPermission('maintenance:update')) return;
                   setHoursInput(Math.round(overview.total_print_hours).toString());
                   setHoursInput(Math.round(overview.total_print_hours).toString());
                   setEditingHours(true);
                   setEditingHours(true);
                 }}
                 }}
-                className="group"
+                className={`group ${!hasPermission('maintenance:update') ? 'cursor-not-allowed opacity-60' : ''}`}
+                title={!hasPermission('maintenance:update') ? 'You do not have permission to edit print hours' : undefined}
               >
               >
-                <div className="text-sm font-medium text-white group-hover:text-bambu-green transition-colors flex items-center gap-1">
+                <div className={`text-sm font-medium text-white ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''} transition-colors flex items-center gap-1`}>
                   {Math.round(overview.total_print_hours)} hours
                   {Math.round(overview.total_print_hours)} hours
-                  <Edit3 className="w-3 h-3 text-bambu-gray group-hover:text-bambu-green" />
+                  <Edit3 className={`w-3 h-3 text-bambu-gray ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''}`} />
                 </div>
                 </div>
                 <div className="text-xs text-bambu-gray">Total print time</div>
                 <div className="text-xs text-bambu-gray">Total print time</div>
               </button>
               </button>
@@ -496,6 +507,7 @@ function PrinterSection({
                 item={item}
                 item={item}
                 onPerform={onPerform}
                 onPerform={onPerform}
                 onToggle={onToggle}
                 onToggle={onToggle}
+                hasPermission={hasPermission}
               />
               />
             ))}
             ))}
           </div>
           </div>
@@ -515,6 +527,7 @@ function SettingsSection({
   onDeleteType,
   onDeleteType,
   onAssignType,
   onAssignType,
   onRemoveItem,
   onRemoveItem,
+  hasPermission,
 }: {
 }: {
   overview: PrinterMaintenanceOverview[] | undefined;
   overview: PrinterMaintenanceOverview[] | undefined;
   types: MaintenanceType[];
   types: MaintenanceType[];
@@ -524,6 +537,7 @@ function SettingsSection({
   onDeleteType: (id: number) => void;
   onDeleteType: (id: number) => void;
   onAssignType: (printerId: number, typeId: number) => void;
   onAssignType: (printerId: number, typeId: number) => void;
   onRemoveItem: (itemId: number) => void;
   onRemoveItem: (itemId: number) => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
   const [editingInterval, setEditingInterval] = useState<number | null>(null);
   const [editingInterval, setEditingInterval] = useState<number | null>(null);
   const [intervalInput, setIntervalInput] = useState('');
   const [intervalInput, setIntervalInput] = useState('');
@@ -654,7 +668,11 @@ function SettingsSection({
             <h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
             <h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
             <p className="text-sm text-bambu-gray mt-1">System types and your custom maintenance tasks</p>
             <p className="text-sm text-bambu-gray mt-1">System types and your custom maintenance tasks</p>
           </div>
           </div>
-          <Button onClick={() => setShowAddType(!showAddType)}>
+          <Button
+            onClick={() => setShowAddType(!showAddType)}
+            disabled={!hasPermission('maintenance:create')}
+            title={!hasPermission('maintenance:create') ? 'You do not have permission to create maintenance types' : undefined}
+          >
             <Plus className="w-4 h-4" />
             <Plus className="w-4 h-4" />
             Add Custom Type
             Add Custom Type
           </Button>
           </Button>
@@ -914,7 +932,9 @@ function SettingsSection({
                   </button>
                   </button>
                   <button
                   <button
                     onClick={() => startEditType(type)}
                     onClick={() => startEditType(type)}
-                    className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
+                    disabled={!hasPermission('maintenance:update')}
+                    title={!hasPermission('maintenance:update') ? 'You do not have permission to edit maintenance types' : undefined}
+                    className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors ${!hasPermission('maintenance:update') ? 'opacity-50 cursor-not-allowed' : ''}`}
                   >
                   >
                     <Edit3 className="w-4 h-4" />
                     <Edit3 className="w-4 h-4" />
                   </button>
                   </button>
@@ -924,7 +944,9 @@ function SettingsSection({
                         onDeleteType(type.id);
                         onDeleteType(type.id);
                       }
                       }
                     }}
                     }}
-                    className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
+                    disabled={!hasPermission('maintenance:delete')}
+                    title={!hasPermission('maintenance:delete') ? 'You do not have permission to delete maintenance types' : undefined}
+                    className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${!hasPermission('maintenance:delete') ? 'opacity-50 cursor-not-allowed' : ''}`}
                   >
                   >
                     <Trash2 className="w-4 h-4" />
                     <Trash2 className="w-4 h-4" />
                   </button>
                   </button>
@@ -946,8 +968,9 @@ function SettingsSection({
                             {p.printerName}
                             {p.printerName}
                             <button
                             <button
                               onClick={() => p.itemId && onRemoveItem(p.itemId)}
                               onClick={() => p.itemId && onRemoveItem(p.itemId)}
-                              className="hover:text-red-400 ml-1"
-                              title="Remove from this printer"
+                              disabled={!hasPermission('maintenance:delete')}
+                              title={!hasPermission('maintenance:delete') ? 'You do not have permission to remove printer assignments' : 'Remove from this printer'}
+                              className={`ml-1 ${hasPermission('maintenance:delete') ? 'hover:text-red-400' : 'opacity-50 cursor-not-allowed'}`}
                             >
                             >
                               ×
                               ×
                             </button>
                             </button>
@@ -962,7 +985,9 @@ function SettingsSection({
                           <button
                           <button
                             key={p.id}
                             key={p.id}
                             onClick={() => onAssignType(p.id, type.id)}
                             onClick={() => onAssignType(p.id, type.id)}
-                            className="px-2 py-1 bg-bambu-dark hover:bg-bambu-green/20 rounded text-xs text-bambu-gray hover:text-bambu-green transition-colors"
+                            disabled={!hasPermission('maintenance:create')}
+                            title={!hasPermission('maintenance:create') ? 'You do not have permission to assign printers' : undefined}
+                            className={`px-2 py-1 bg-bambu-dark rounded text-xs transition-colors ${hasPermission('maintenance:create') ? 'hover:bg-bambu-green/20 text-bambu-gray hover:text-bambu-green' : 'opacity-50 cursor-not-allowed text-bambu-gray'}`}
                           >
                           >
                             + {p.name}
                             + {p.name}
                           </button>
                           </button>
@@ -1034,11 +1059,14 @@ function SettingsSection({
                           ) : (
                           ) : (
                             <button
                             <button
                               onClick={() => {
                               onClick={() => {
+                                if (!hasPermission('maintenance:update')) return;
                                 setEditingInterval(item.id);
                                 setEditingInterval(item.id);
                                 setIntervalInput(item.interval_hours.toString());
                                 setIntervalInput(item.interval_hours.toString());
                                 setIntervalTypeInput(intervalType);
                                 setIntervalTypeInput(intervalType);
                               }}
                               }}
-                              className="px-2 py-1 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green rounded text-xs font-medium text-white transition-colors flex items-center gap-1"
+                              disabled={!hasPermission('maintenance:update')}
+                              title={!hasPermission('maintenance:update') ? 'You do not have permission to edit intervals' : undefined}
+                              className={`px-2 py-1 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs font-medium text-white transition-colors flex items-center gap-1 ${hasPermission('maintenance:update') ? 'hover:bg-bambu-dark-secondary hover:border-bambu-green' : 'opacity-50 cursor-not-allowed'}`}
                             >
                             >
                               {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
                               {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
                               {formatIntervalLabel(item.interval_hours, intervalType)}
                               {formatIntervalLabel(item.interval_hours, intervalType)}
@@ -1076,6 +1104,7 @@ type TabType = 'status' | 'settings';
 export function MaintenancePage() {
 export function MaintenancePage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [activeTab, setActiveTab] = useState<TabType>('status');
   const [activeTab, setActiveTab] = useState<TabType>('status');
 
 
   const { data: overview, isLoading } = useQuery({
   const { data: overview, isLoading } = useQuery({
@@ -1259,6 +1288,7 @@ export function MaintenancePage() {
                 onPerform={handlePerform}
                 onPerform={handlePerform}
                 onToggle={handleToggle}
                 onToggle={handleToggle}
                 onSetHours={handleSetHours}
                 onSetHours={handleSetHours}
+                hasPermission={hasPermission}
               />
               />
             ))
             ))
           ) : (
           ) : (
@@ -1293,6 +1323,7 @@ export function MaintenancePage() {
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
           onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
           onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
           onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
           onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
+          hasPermission={hasPermission}
         />
         />
       )}
       )}
     </div>
     </div>

+ 100 - 49
frontend/src/pages/PrintersPage.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
 import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTheme } from '../contexts/ThemeContext';
 import { useTheme } from '../contexts/ThemeContext';
+import { useAuth } from '../contexts/AuthContext';
 import {
 import {
   Plus,
   Plus,
   Link,
   Link,
@@ -938,6 +939,7 @@ function PrinterCard({
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const navigate = useNavigate();
   const navigate = useNavigate();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [showMenu, setShowMenu] = useState(false);
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [deleteArchives, setDeleteArchives] = useState(true);
   const [deleteArchives, setDeleteArchives] = useState(true);
@@ -1127,6 +1129,7 @@ function PrinterCard({
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
     },
     },
+    onError: (error: Error) => showToast(error.message || 'Failed to delete printer', 'error'),
   });
   });
 
 
   const connectMutation = useMutation({
   const connectMutation = useMutation({
@@ -1560,11 +1563,17 @@ function PrinterCard({
               {showMenu && (
               {showMenu && (
                 <div className="absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10">
                 <div className="absolute right-0 mt-2 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10">
                   <button
                   <button
-                    className="w-full px-4 py-2 text-left text-sm hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                    className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
+                      hasPermission('printers:update')
+                        ? 'hover:bg-bambu-dark-tertiary'
+                        : 'opacity-50 cursor-not-allowed'
+                    }`}
                     onClick={() => {
                     onClick={() => {
+                      if (!hasPermission('printers:update')) return;
                       setShowEditModal(true);
                       setShowEditModal(true);
                       setShowMenu(false);
                       setShowMenu(false);
                     }}
                     }}
+                    title={!hasPermission('printers:update') ? 'You do not have permission to edit printers' : undefined}
                   >
                   >
                     <Pencil className="w-4 h-4" />
                     <Pencil className="w-4 h-4" />
                     Edit
                     Edit
@@ -1590,11 +1599,17 @@ function PrinterCard({
                     MQTT Debug
                     MQTT Debug
                   </button>
                   </button>
                   <button
                   <button
-                    className="w-full px-4 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                    className={`w-full px-4 py-2 text-left text-sm flex items-center gap-2 ${
+                      hasPermission('printers:delete')
+                        ? 'text-red-400 hover:bg-bambu-dark-tertiary'
+                        : 'text-red-400/50 cursor-not-allowed'
+                    }`}
                     onClick={() => {
                     onClick={() => {
+                      if (!hasPermission('printers:delete')) return;
                       setShowDeleteConfirm(true);
                       setShowDeleteConfirm(true);
                       setShowMenu(false);
                       setShowMenu(false);
                     }}
                     }}
+                    title={!hasPermission('printers:delete') ? 'You do not have permission to delete printers' : undefined}
                   >
                   >
                     <Trash2 className="w-4 h-4" />
                     <Trash2 className="w-4 h-4" />
                     Delete
                     Delete
@@ -1801,18 +1816,20 @@ function PrinterCard({
                   {/* Skip Objects button - top right corner, always visible */}
                   {/* Skip Objects button - top right corner, always visible */}
                   <button
                   <button
                     onClick={() => setShowSkipObjectsModal(true)}
                     onClick={() => setShowSkipObjectsModal(true)}
-                    disabled={!(status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED') || (status.printable_objects_count ?? 0) < 2}
+                    disabled={!(status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED') || (status.printable_objects_count ?? 0) < 2 || !hasPermission('printers:control')}
                     className={`absolute top-2 right-2 p-1.5 rounded transition-colors z-10 ${
                     className={`absolute top-2 right-2 p-1.5 rounded transition-colors z-10 ${
-                      (status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED') && (status.printable_objects_count ?? 0) >= 2
+                      (status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED') && (status.printable_objects_count ?? 0) >= 2 && hasPermission('printers:control')
                         ? 'text-bambu-gray hover:text-white hover:bg-white/10'
                         ? 'text-bambu-gray hover:text-white hover:bg-white/10'
                         : 'text-bambu-gray/30 cursor-not-allowed'
                         : 'text-bambu-gray/30 cursor-not-allowed'
                     }`}
                     }`}
                     title={
                     title={
-                      !(status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED')
-                        ? "Skip objects (only while printing)"
-                        : (status.printable_objects_count ?? 0) >= 2
-                          ? "Skip objects"
-                          : "Skip objects (requires 2+ objects)"
+                      !hasPermission('printers:control')
+                        ? "You do not have permission to control printers"
+                        : !(status.state === 'RUNNING' || status.state === 'PAUSE' || status.state === 'PAUSED')
+                          ? "Skip objects (only while printing)"
+                          : (status.printable_objects_count ?? 0) >= 2
+                            ? "Skip objects"
+                            : "Skip objects (requires 2+ objects)"
                     }
                     }
                   >
                   >
                     <SkipObjectsIcon className="w-4 h-4" />
                     <SkipObjectsIcon className="w-4 h-4" />
@@ -2027,16 +2044,16 @@ function PrinterCard({
                       {/* Stop button */}
                       {/* Stop button */}
                       <button
                       <button
                         onClick={() => setShowStopConfirm(true)}
                         onClick={() => setShowStopConfirm(true)}
-                        disabled={!isPrinting || isControlBusy}
+                        disabled={!isPrinting || isControlBusy || !hasPermission('printers:control')}
                         className={`
                         className={`
                           flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
                           flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
                           transition-colors
                           transition-colors
-                          ${isPrinting
+                          ${isPrinting && hasPermission('printers:control')
                             ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
                             ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
                             : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
                             : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
                           }
                           }
                         `}
                         `}
-                        title="Stop print"
+                        title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : 'Stop print'}
                       >
                       >
                         <Square className="w-3 h-3" />
                         <Square className="w-3 h-3" />
                         Stop
                         Stop
@@ -2045,18 +2062,18 @@ function PrinterCard({
                       {/* Pause/Resume button */}
                       {/* Pause/Resume button */}
                       <button
                       <button
                         onClick={() => isPaused ? setShowResumeConfirm(true) : setShowPauseConfirm(true)}
                         onClick={() => isPaused ? setShowResumeConfirm(true) : setShowPauseConfirm(true)}
-                        disabled={!isPrinting || isControlBusy}
+                        disabled={!isPrinting || isControlBusy || !hasPermission('printers:control')}
                         className={`
                         className={`
                           flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
                           flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
                           transition-colors
                           transition-colors
-                          ${isPrinting
+                          ${isPrinting && hasPermission('printers:control')
                             ? isPaused
                             ? isPaused
                               ? 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
                               ? 'bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30'
                               : 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
                               : 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30'
                             : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
                             : 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
                           }
                           }
                         `}
                         `}
-                        title={isPaused ? 'Resume print' : 'Pause print'}
+                        title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : (isPaused ? 'Resume print' : 'Pause print')}
                       >
                       >
                         {isPaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
                         {isPaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />}
                         {isPaused ? 'Resume' : 'Pause'}
                         {isPaused ? 'Resume' : 'Pause'}
@@ -2234,13 +2251,19 @@ function PrinterCard({
                                     {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === slotIdx && (
                                     {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === slotIdx && (
                                       <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                       <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                         <button
                                         <button
-                                          className="w-full px-3 py-1.5 text-left text-xs text-white hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                                          className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
+                                            hasPermission('printers:control')
+                                              ? 'text-white hover:bg-bambu-dark-tertiary'
+                                              : 'text-bambu-gray/50 cursor-not-allowed'
+                                          }`}
                                           onClick={(e) => {
                                           onClick={(e) => {
                                             e.stopPropagation();
                                             e.stopPropagation();
+                                            if (!hasPermission('printers:control')) return;
                                             refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });
                                             refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });
                                             setAmsSlotMenu(null);
                                             setAmsSlotMenu(null);
                                           }}
                                           }}
-                                          disabled={isRefreshing}
+                                          disabled={isRefreshing || !hasPermission('printers:control')}
+                                          title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : undefined}
                                         >
                                         >
                                           <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
                                           <RefreshCw className={`w-3 h-3 ${isRefreshing ? 'animate-spin' : ''}`} />
                                           Re-read RFID
                                           Re-read RFID
@@ -2266,7 +2289,7 @@ function PrinterCard({
                                           } : undefined,
                                           } : undefined,
                                         }}
                                         }}
                                         configureSlot={{
                                         configureSlot={{
-                                          enabled: true,
+                                          enabled: hasPermission('printers:control'),
                                           onConfigure: () => setConfigureSlotModal({
                                           onConfigure: () => setConfigureSlotModal({
                                             amsId: ams.id,
                                             amsId: ams.id,
                                             trayId: slotIdx,
                                             trayId: slotIdx,
@@ -2283,7 +2306,7 @@ function PrinterCard({
                                     ) : (
                                     ) : (
                                       <EmptySlotHoverCard
                                       <EmptySlotHoverCard
                                         configureSlot={{
                                         configureSlot={{
-                                          enabled: true,
+                                          enabled: hasPermission('printers:control'),
                                           onConfigure: () => setConfigureSlotModal({
                                           onConfigure: () => setConfigureSlotModal({
                                             amsId: ams.id,
                                             amsId: ams.id,
                                             trayId: slotIdx,
                                             trayId: slotIdx,
@@ -2417,13 +2440,19 @@ function PrinterCard({
                                 {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === htSlotId && (
                                 {status?.state !== 'RUNNING' && amsSlotMenu?.amsId === ams.id && amsSlotMenu?.slotId === htSlotId && (
                                   <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                   <div className="absolute top-full left-0 mt-1 z-50 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                                     <button
                                     <button
-                                      className="w-full px-3 py-1.5 text-left text-xs text-white hover:bg-bambu-dark-tertiary flex items-center gap-2"
+                                      className={`w-full px-3 py-1.5 text-left text-xs flex items-center gap-2 ${
+                                        hasPermission('printers:control')
+                                          ? 'text-white hover:bg-bambu-dark-tertiary'
+                                          : 'text-bambu-gray/50 cursor-not-allowed'
+                                      }`}
                                       onClick={(e) => {
                                       onClick={(e) => {
                                         e.stopPropagation();
                                         e.stopPropagation();
+                                        if (!hasPermission('printers:control')) return;
                                         refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });
                                         refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });
                                         setAmsSlotMenu(null);
                                         setAmsSlotMenu(null);
                                       }}
                                       }}
-                                      disabled={isHtRefreshing}
+                                      disabled={isHtRefreshing || !hasPermission('printers:control')}
+                                      title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : undefined}
                                     >
                                     >
                                       <RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
                                       <RefreshCw className={`w-3 h-3 ${isHtRefreshing ? 'animate-spin' : ''}`} />
                                       Re-read RFID
                                       Re-read RFID
@@ -2449,7 +2478,7 @@ function PrinterCard({
                                       } : undefined,
                                       } : undefined,
                                     }}
                                     }}
                                     configureSlot={{
                                     configureSlot={{
-                                      enabled: true,
+                                      enabled: hasPermission('printers:control'),
                                       onConfigure: () => setConfigureSlotModal({
                                       onConfigure: () => setConfigureSlotModal({
                                         amsId: ams.id,
                                         amsId: ams.id,
                                         trayId: htSlotId,
                                         trayId: htSlotId,
@@ -2466,7 +2495,7 @@ function PrinterCard({
                                 ) : (
                                 ) : (
                                   <EmptySlotHoverCard
                                   <EmptySlotHoverCard
                                     configureSlot={{
                                     configureSlot={{
-                                      enabled: true,
+                                      enabled: hasPermission('printers:control'),
                                       onConfigure: () => setConfigureSlotModal({
                                       onConfigure: () => setConfigureSlotModal({
                                         amsId: ams.id,
                                         amsId: ams.id,
                                         trayId: htSlotId,
                                         trayId: htSlotId,
@@ -2577,7 +2606,7 @@ function PrinterCard({
                                 } : undefined,
                                 } : undefined,
                               }}
                               }}
                               configureSlot={{
                               configureSlot={{
-                                enabled: true,
+                                enabled: hasPermission('printers:control'),
                                 onConfigure: () => setConfigureSlotModal({
                                 onConfigure: () => setConfigureSlotModal({
                                   amsId: 255, // External spool indicator
                                   amsId: 255, // External spool indicator
                                   trayId: 0,
                                   trayId: 0,
@@ -2636,24 +2665,30 @@ function PrinterCard({
               <div className="flex items-center gap-1">
               <div className="flex items-center gap-1">
                 <button
                 <button
                   onClick={() => setShowPowerOnConfirm(true)}
                   onClick={() => setShowPowerOnConfirm(true)}
-                  disabled={powerControlMutation.isPending || plugStatus?.state === 'ON'}
+                  disabled={powerControlMutation.isPending || plugStatus?.state === 'ON' || !hasPermission('smart_plugs:control')}
                   className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
                   className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
-                    plugStatus?.state === 'ON'
-                      ? 'bg-bambu-green text-white'
-                      : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                    !hasPermission('smart_plugs:control')
+                      ? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
+                      : plugStatus?.state === 'ON'
+                        ? 'bg-bambu-green text-white'
+                        : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
                   }`}
                   }`}
+                  title={!hasPermission('smart_plugs:control') ? 'You do not have permission to control smart plugs' : undefined}
                 >
                 >
                   <Power className="w-3 h-3" />
                   <Power className="w-3 h-3" />
                   On
                   On
                 </button>
                 </button>
                 <button
                 <button
                   onClick={() => setShowPowerOffConfirm(true)}
                   onClick={() => setShowPowerOffConfirm(true)}
-                  disabled={powerControlMutation.isPending || plugStatus?.state === 'OFF'}
+                  disabled={powerControlMutation.isPending || plugStatus?.state === 'OFF' || !hasPermission('smart_plugs:control')}
                   className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
                   className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
-                    plugStatus?.state === 'OFF'
-                      ? 'bg-red-500/30 text-red-400'
-                      : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
+                    !hasPermission('smart_plugs:control')
+                      ? 'bg-bambu-dark text-bambu-gray/50 cursor-not-allowed'
+                      : plugStatus?.state === 'OFF'
+                        ? 'bg-red-500/30 text-red-400'
+                        : 'bg-bambu-dark text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
                   }`}
                   }`}
+                  title={!hasPermission('smart_plugs:control') ? 'You do not have permission to control smart plugs' : undefined}
                 >
                 >
                   <PowerOff className="w-3 h-3" />
                   <PowerOff className="w-3 h-3" />
                   Off
                   Off
@@ -2667,12 +2702,14 @@ function PrinterCard({
                 </span>
                 </span>
                 <button
                 <button
                   onClick={() => toggleAutoOffMutation.mutate(!smartPlug.auto_off)}
                   onClick={() => toggleAutoOffMutation.mutate(!smartPlug.auto_off)}
-                  disabled={toggleAutoOffMutation.isPending || smartPlug.auto_off_executed}
-                  title={smartPlug.auto_off_executed ? 'Auto-off was executed - turn printer on to reset' : 'Auto power-off after print'}
+                  disabled={toggleAutoOffMutation.isPending || smartPlug.auto_off_executed || !hasPermission('smart_plugs:control')}
+                  title={!hasPermission('smart_plugs:control') ? 'You do not have permission to control smart plugs' : (smartPlug.auto_off_executed ? 'Auto-off was executed - turn printer on to reset' : 'Auto power-off after print')}
                   className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
                   className={`relative w-9 h-5 rounded-full transition-colors flex-shrink-0 ${
-                    smartPlug.auto_off_executed
-                      ? 'bg-bambu-green/50 cursor-not-allowed'
-                      : smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                    !hasPermission('smart_plugs:control')
+                      ? 'bg-bambu-dark-tertiary/50 cursor-not-allowed'
+                      : smartPlug.auto_off_executed
+                        ? 'bg-bambu-green/50 cursor-not-allowed'
+                        : smartPlug.auto_off ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
                   }`}
                   }`}
                 >
                 >
                   <span
                   <span
@@ -2699,8 +2736,8 @@ function PrinterCard({
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
                 onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
                 onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
-                disabled={!status?.connected || chamberLightMutation.isPending}
-                title={status?.chamber_light ? 'Turn off chamber light' : 'Turn on chamber light'}
+                disabled={!status?.connected || chamberLightMutation.isPending || !hasPermission('printers:control')}
+                title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : (status?.chamber_light ? 'Turn off chamber light' : 'Turn on chamber light')}
                 className={status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30 border-yellow-500/30' : ''}
                 className={status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30 border-yellow-500/30' : ''}
               >
               >
                 <ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
                 <ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
@@ -2737,8 +2774,8 @@ function PrinterCard({
                   variant="secondary"
                   variant="secondary"
                   size="sm"
                   size="sm"
                   onClick={handleTogglePlateDetection}
                   onClick={handleTogglePlateDetection}
-                  disabled={!status?.connected || plateDetectionMutation.isPending}
-                  title={printer.plate_detection_enabled ? "Plate check enabled - Click to disable" : "Plate check disabled - Click to enable"}
+                  disabled={!status?.connected || plateDetectionMutation.isPending || !hasPermission('printers:update')}
+                  title={!hasPermission('printers:update') ? "You do not have permission to update printers" : (printer.plate_detection_enabled ? "Plate check enabled - Click to disable" : "Plate check disabled - Click to enable")}
                   className={`!rounded-r-none !border-r-0 ${printer.plate_detection_enabled ? "!border-green-500 !text-green-400 hover:!bg-green-500/20" : ""}`}
                   className={`!rounded-r-none !border-r-0 ${printer.plate_detection_enabled ? "!border-green-500 !text-green-400 hover:!bg-green-500/20" : ""}`}
                 >
                 >
                   {plateDetectionMutation.isPending ? (
                   {plateDetectionMutation.isPending ? (
@@ -2751,8 +2788,8 @@ function PrinterCard({
                   variant="secondary"
                   variant="secondary"
                   size="sm"
                   size="sm"
                   onClick={handleOpenPlateManagement}
                   onClick={handleOpenPlateManagement}
-                  disabled={!status?.connected || isCheckingPlate}
-                  title="Manage plate detection calibration"
+                  disabled={!status?.connected || isCheckingPlate || !hasPermission('printers:update')}
+                  title={!hasPermission('printers:update') ? "You do not have permission to update printers" : "Manage plate detection calibration"}
                   className={`!rounded-l-none !px-1.5 ${printer.plate_detection_enabled ? "!border-green-500 !text-green-400 hover:!bg-green-500/20" : ""}`}
                   className={`!rounded-l-none !px-1.5 ${printer.plate_detection_enabled ? "!border-green-500 !text-green-400 hover:!bg-green-500/20" : ""}`}
                 >
                 >
                   {isCheckingPlate ? (
                   {isCheckingPlate ? (
@@ -2766,7 +2803,8 @@ function PrinterCard({
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
                 onClick={() => setShowFileManager(true)}
                 onClick={() => setShowFileManager(true)}
-                title="Browse printer files"
+                disabled={!hasPermission('printers:files')}
+                title={!hasPermission('printers:files') ? 'You do not have permission to access printer files' : 'Browse printer files'}
               >
               >
                 <HardDrive className="w-4 h-4" />
                 <HardDrive className="w-4 h-4" />
                 Files
                 Files
@@ -3332,13 +3370,13 @@ function PrinterCard({
                       {!obj.skipped ? (
                       {!obj.skipped ? (
                         <button
                         <button
                           onClick={() => skipObjectsMutation.mutate([obj.id])}
                           onClick={() => skipObjectsMutation.mutate([obj.id])}
-                          disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1}
+                          disabled={skipObjectsMutation.isPending || (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')}
                           className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
                           className={`px-4 py-2 text-xs font-medium rounded-lg transition-colors ${
-                            (status?.layer_num ?? 0) <= 1
+                            (status?.layer_num ?? 0) <= 1 || !hasPermission('printers:control')
                               ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
                               ? 'bg-gray-100 dark:bg-bambu-dark text-gray-400 dark:text-bambu-gray/50 cursor-not-allowed'
                               : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
                               : 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-500/30 border border-red-300 dark:border-red-500/30'
                           }`}
                           }`}
-                          title={(status?.layer_num ?? 0) <= 1 ? 'Wait for layer 2+' : 'Skip this object'}
+                          title={!hasPermission('printers:control') ? 'You do not have permission to control printers' : ((status?.layer_num ?? 0) <= 1 ? 'Wait for layer 2+' : 'Skip this object')}
                         >
                         >
                           Skip
                           Skip
                         </button>
                         </button>
@@ -4026,6 +4064,7 @@ function EditPrinterModal({
   onClose: () => void;
   onClose: () => void;
 }) {
 }) {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
+  const { showToast } = useToast();
   const [form, setForm] = useState({
   const [form, setForm] = useState({
     name: printer.name,
     name: printer.name,
     ip_address: printer.ip_address,
     ip_address: printer.ip_address,
@@ -4042,6 +4081,7 @@ function EditPrinterModal({
       queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
       queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
       onClose();
       onClose();
     },
     },
+    onError: (error: Error) => showToast(error.message || 'Failed to update printer', 'error'),
   });
   });
 
 
   // Close on Escape key
   // Close on Escape key
@@ -4277,6 +4317,8 @@ export function PrintersPage() {
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   // Embedded camera viewer state - supports multiple simultaneous viewers
   // Embedded camera viewer state - supports multiple simultaneous viewers
   // Persisted to localStorage so cameras reopen after navigation
   // Persisted to localStorage so cameras reopen after navigation
   const [embeddedCameraPrinters, setEmbeddedCameraPrinters] = useState<Map<number, { id: number; name: string }>>(() => {
   const [embeddedCameraPrinters, setEmbeddedCameraPrinters] = useState<Map<number, { id: number; name: string }>>(() => {
@@ -4382,6 +4424,7 @@ export function PrintersPage() {
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
       setShowAddModal(false);
       setShowAddModal(false);
     },
     },
+    onError: (error: Error) => showToast(error.message || 'Failed to add printer', 'error'),
   });
   });
 
 
   const powerOnMutation = useMutation({
   const powerOnMutation = useMutation({
@@ -4602,7 +4645,11 @@ export function PrintersPage() {
               )}
               )}
             </div>
             </div>
           )}
           )}
-          <Button onClick={() => setShowAddModal(true)}>
+          <Button
+            onClick={() => setShowAddModal(true)}
+            disabled={!hasPermission('printers:create')}
+            title={!hasPermission('printers:create') ? 'You do not have permission to add printers' : undefined}
+          >
             <Plus className="w-4 h-4" />
             <Plus className="w-4 h-4" />
             Add Printer
             Add Printer
           </Button>
           </Button>
@@ -4615,7 +4662,11 @@ export function PrintersPage() {
         <Card>
         <Card>
           <CardContent className="text-center py-12">
           <CardContent className="text-center py-12">
             <p className="text-bambu-gray mb-4">No printers configured yet</p>
             <p className="text-bambu-gray mb-4">No printers configured yet</p>
-            <Button onClick={() => setShowAddModal(true)}>
+            <Button
+              onClick={() => setShowAddModal(true)}
+              disabled={!hasPermission('printers:create')}
+              title={!hasPermission('printers:create') ? 'You do not have permission to add printers' : undefined}
+            >
               <Plus className="w-4 h-4" />
               <Plus className="w-4 h-4" />
               Add Your First Printer
               Add Your First Printer
             </Button>
             </Button>

+ 46 - 8
frontend/src/pages/ProfilesPage.tsx

@@ -42,10 +42,11 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate } from '../utils/date';
 import { parseUTCDate } from '../utils/date';
-import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition } from '../api/client';
+import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 import { KProfilesView } from '../components/KProfilesView';
 import { KProfilesView } from '../components/KProfilesView';
 
 
 type ProfileTab = 'cloud' | 'kprofiles';
 type ProfileTab = 'cloud' | 'kprofiles';
@@ -506,12 +507,14 @@ function PresetDetailModal({
   onDeleted,
   onDeleted,
   onDuplicate,
   onDuplicate,
   onEdit,
   onEdit,
+  hasPermission,
 }: {
 }: {
   setting: SlicerSetting;
   setting: SlicerSetting;
   onClose: () => void;
   onClose: () => void;
   onDeleted: () => void;
   onDeleted: () => void;
   onDuplicate: () => void;
   onDuplicate: () => void;
   onEdit: () => void;
   onEdit: () => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
   const { showToast } = useToast();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -599,17 +602,32 @@ function PresetDetailModal({
             <div className="flex-shrink-0 p-4 border-t border-bambu-dark-tertiary">
             <div className="flex-shrink-0 p-4 border-t border-bambu-dark-tertiary">
               <div className="flex gap-2">
               <div className="flex gap-2">
                 <Button variant="secondary" onClick={onClose} className="flex-1">Close</Button>
                 <Button variant="secondary" onClick={onClose} className="flex-1">Close</Button>
-                <Button variant="secondary" onClick={onDuplicate}>
+                <Button
+                  variant="secondary"
+                  onClick={onDuplicate}
+                  disabled={!hasPermission('cloud:auth')}
+                  title={!hasPermission('cloud:auth') ? 'You do not have permission to duplicate presets' : undefined}
+                >
                   <Copy className="w-4 h-4" />
                   <Copy className="w-4 h-4" />
                   Duplicate
                   Duplicate
                 </Button>
                 </Button>
                 {isEditable && (
                 {isEditable && (
                   <>
                   <>
-                    <Button variant="secondary" onClick={onEdit} disabled={isLoading || !detail}>
+                    <Button
+                      variant="secondary"
+                      onClick={onEdit}
+                      disabled={isLoading || !detail || !hasPermission('cloud:auth')}
+                      title={!hasPermission('cloud:auth') ? 'You do not have permission to edit presets' : undefined}
+                    >
                       <Pencil className="w-4 h-4" />
                       <Pencil className="w-4 h-4" />
                       Edit
                       Edit
                     </Button>
                     </Button>
-                    <Button variant="danger" onClick={() => setShowDeleteConfirm(true)}>
+                    <Button
+                      variant="danger"
+                      onClick={() => setShowDeleteConfirm(true)}
+                      disabled={!hasPermission('cloud:auth')}
+                      title={!hasPermission('cloud:auth') ? 'You do not have permission to delete presets' : undefined}
+                    >
                       <Trash2 className="w-4 h-4" />
                       <Trash2 className="w-4 h-4" />
                     </Button>
                     </Button>
                   </>
                   </>
@@ -2206,12 +2224,14 @@ function CloudProfilesView({
   onRefresh,
   onRefresh,
   isRefreshing,
   isRefreshing,
   printers,
   printers,
+  hasPermission,
 }: {
 }: {
   settings: SlicerSettingsResponse;
   settings: SlicerSettingsResponse;
   lastSyncTime?: Date;
   lastSyncTime?: Date;
   onRefresh: () => void;
   onRefresh: () => void;
   isRefreshing: boolean;
   isRefreshing: boolean;
   printers: Printer[];
   printers: Printer[];
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
   const [filterType, setFilterType] = useState<PresetType>('all');
   const [filterType, setFilterType] = useState<PresetType>('all');
@@ -2424,15 +2444,29 @@ function CloudProfilesView({
               <GitCompare className="w-4 h-4" />
               <GitCompare className="w-4 h-4" />
               {compareMode ? 'Cancel' : 'Compare'}
               {compareMode ? 'Cancel' : 'Compare'}
             </Button>
             </Button>
-            <Button variant="secondary" onClick={() => setShowTemplatesModal(true)}>
+            <Button
+              variant="secondary"
+              onClick={() => setShowTemplatesModal(true)}
+              disabled={!hasPermission('cloud:auth')}
+              title={!hasPermission('cloud:auth') ? 'You do not have permission to manage templates' : undefined}
+            >
               <Sparkles className="w-4 h-4" />
               <Sparkles className="w-4 h-4" />
               Templates
               Templates
             </Button>
             </Button>
-            <Button variant="secondary" onClick={onRefresh} disabled={isRefreshing}>
+            <Button
+              variant="secondary"
+              onClick={onRefresh}
+              disabled={isRefreshing || !hasPermission('cloud:auth')}
+              title={!hasPermission('cloud:auth') ? 'You do not have permission to refresh profiles' : undefined}
+            >
               <RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
               <RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
               Refresh
               Refresh
             </Button>
             </Button>
-            <Button onClick={() => setShowCreateModal(true)}>
+            <Button
+              onClick={() => setShowCreateModal(true)}
+              disabled={!hasPermission('cloud:auth')}
+              title={!hasPermission('cloud:auth') ? 'You do not have permission to create presets' : undefined}
+            >
               <Plus className="w-4 h-4" />
               <Plus className="w-4 h-4" />
               New Preset
               New Preset
             </Button>
             </Button>
@@ -2704,6 +2738,7 @@ function CloudProfilesView({
           onDeleted={() => setSelectedSetting(null)}
           onDeleted={() => setSelectedSetting(null)}
           onDuplicate={() => handleDuplicate(selectedSetting)}
           onDuplicate={() => handleDuplicate(selectedSetting)}
           onEdit={() => handleEdit(selectedSetting)}
           onEdit={() => handleEdit(selectedSetting)}
+          hasPermission={hasPermission}
         />
         />
       )}
       )}
 
 
@@ -2748,6 +2783,7 @@ function CloudProfilesView({
 export function ProfilesPage() {
 export function ProfilesPage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [activeTab, setActiveTab] = useState<ProfileTab>('cloud');
   const [activeTab, setActiveTab] = useState<ProfileTab>('cloud');
   const [lastSyncTime, setLastSyncTime] = useState<Date>();
   const [lastSyncTime, setLastSyncTime] = useState<Date>();
 
 
@@ -2846,7 +2882,8 @@ export function ProfilesPage() {
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"
                 onClick={() => logoutMutation.mutate()}
                 onClick={() => logoutMutation.mutate()}
-                disabled={logoutMutation.isPending}
+                disabled={logoutMutation.isPending || !hasPermission('cloud:auth')}
+                title={!hasPermission('cloud:auth') ? 'You do not have permission to logout' : undefined}
               >
               >
                 <LogOut className="w-4 h-4" />
                 <LogOut className="w-4 h-4" />
                 Logout
                 Logout
@@ -2867,6 +2904,7 @@ export function ProfilesPage() {
               onRefresh={() => refetchSettings()}
               onRefresh={() => refetchSettings()}
               isRefreshing={settingsLoading}
               isRefreshing={settingsLoading}
               printers={printers}
               printers={printers}
+              hasPermission={hasPermission}
             />
             />
           ) : (
           ) : (
             <div className="text-center py-16">
             <div className="text-center py-16">

+ 51 - 14
frontend/src/pages/ProjectDetailPage.tsx

@@ -36,6 +36,7 @@ import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } fr
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 import { RichTextEditor } from '../components/RichTextEditor';
 import { RichTextEditor } from '../components/RichTextEditor';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 
 
@@ -198,6 +199,7 @@ export function ProjectDetailPage() {
   const navigate = useNavigate();
   const navigate = useNavigate();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [showEditModal, setShowEditModal] = useState(false);
   const [showEditModal, setShowEditModal] = useState(false);
   const [editingNotes, setEditingNotes] = useState(false);
   const [editingNotes, setEditingNotes] = useState(false);
   const [notesContent, setNotesContent] = useState('');
   const [notesContent, setNotesContent] = useState('');
@@ -494,11 +496,20 @@ export function ProjectDetailPage() {
           <StatusBadge status={project.status} />
           <StatusBadge status={project.status} />
         </div>
         </div>
         <div className="flex gap-2">
         <div className="flex gap-2">
-          <Button variant="secondary" onClick={handleExportProject} title="Export project">
+          <Button
+            variant="secondary"
+            onClick={handleExportProject}
+            disabled={!hasPermission('projects:read')}
+            title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export project'}
+          >
             <Download className="w-4 h-4 mr-2" />
             <Download className="w-4 h-4 mr-2" />
             Export
             Export
           </Button>
           </Button>
-          <Button onClick={() => setShowEditModal(true)}>
+          <Button
+            onClick={() => setShowEditModal(true)}
+            disabled={!hasPermission('projects:update')}
+            title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
+          >
             <Edit3 className="w-4 h-4 mr-2" />
             <Edit3 className="w-4 h-4 mr-2" />
             Edit
             Edit
           </Button>
           </Button>
@@ -761,7 +772,13 @@ export function ProjectDetailPage() {
               Notes
               Notes
             </h2>
             </h2>
             {!editingNotes ? (
             {!editingNotes ? (
-              <Button variant="secondary" size="sm" onClick={handleStartEditNotes}>
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={handleStartEditNotes}
+                disabled={!hasPermission('projects:update')}
+                title={!hasPermission('projects:update') ? 'You do not have permission to edit notes' : undefined}
+              >
                 <Edit3 className="w-4 h-4 mr-1" />
                 <Edit3 className="w-4 h-4 mr-1" />
                 Edit
                 Edit
               </Button>
               </Button>
@@ -886,7 +903,13 @@ export function ProjectDetailPage() {
                 </button>
                 </button>
               )}
               )}
               {!showBomForm && (
               {!showBomForm && (
-                <Button variant="secondary" size="sm" onClick={() => setShowBomForm(true)}>
+                <Button
+                  variant="secondary"
+                  size="sm"
+                  onClick={() => setShowBomForm(true)}
+                  disabled={!hasPermission('projects:update')}
+                  title={!hasPermission('projects:update') ? 'You do not have permission to add parts' : undefined}
+                >
                   <Plus className="w-4 h-4 mr-1" />
                   <Plus className="w-4 h-4 mr-1" />
                   Add Part
                   Add Part
                 </Button>
                 </Button>
@@ -1031,12 +1054,15 @@ export function ProjectDetailPage() {
                     // Display mode
                     // Display mode
                     <div className="flex items-start gap-3">
                     <div className="flex items-start gap-3">
                       <button
                       <button
-                        onClick={() => handleToggleAcquired(item)}
-                        disabled={updateBomMutation.isPending}
+                        onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
+                        disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
+                        title={!hasPermission('projects:update') ? 'You do not have permission to update parts' : undefined}
                         className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
                         className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
                           item.is_complete
                           item.is_complete
                             ? 'bg-status-ok border-status-ok text-white'
                             ? 'bg-status-ok border-status-ok text-white'
-                            : 'border-bambu-gray hover:border-bambu-green'
+                            : hasPermission('projects:update')
+                              ? 'border-bambu-gray hover:border-bambu-green'
+                              : 'border-bambu-gray/50 cursor-not-allowed'
                         }`}
                         }`}
                       >
                       >
                         {item.is_complete && <CheckCircle className="w-3 h-3" />}
                         {item.is_complete && <CheckCircle className="w-3 h-3" />}
@@ -1058,16 +1084,26 @@ export function ProjectDetailPage() {
                           </div>
                           </div>
                           <div className="flex items-center gap-1">
                           <div className="flex items-center gap-1">
                             <button
                             <button
-                              onClick={() => handleEditBomItem(item)}
-                              className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0"
-                              title="Edit"
+                              onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}
+                              disabled={!hasPermission('projects:update')}
+                              className={`p-1 rounded transition-colors flex-shrink-0 ${
+                                hasPermission('projects:update')
+                                  ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
+                                  : 'text-bambu-gray/50 cursor-not-allowed'
+                              }`}
+                              title={!hasPermission('projects:update') ? 'You do not have permission to edit parts' : 'Edit'}
                             >
                             >
                               <Pencil className="w-4 h-4" />
                               <Pencil className="w-4 h-4" />
                             </button>
                             </button>
                             <button
                             <button
-                              onClick={() => handleDeleteBomItem(item.id, item.name)}
-                              className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
-                              title="Delete"
+                              onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}
+                              disabled={!hasPermission('projects:update')}
+                              className={`p-1 rounded transition-colors flex-shrink-0 ${
+                                hasPermission('projects:update')
+                                  ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
+                                  : 'text-bambu-gray/50 cursor-not-allowed'
+                              }`}
+                              title={!hasPermission('projects:update') ? 'You do not have permission to delete parts' : 'Delete'}
                             >
                             >
                               <Trash2 className="w-4 h-4" />
                               <Trash2 className="w-4 h-4" />
                             </button>
                             </button>
@@ -1178,7 +1214,8 @@ export function ProjectDetailPage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => createTemplateMutation.mutate()}
             onClick={() => createTemplateMutation.mutate()}
-            disabled={createTemplateMutation.isPending}
+            disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
+            title={!hasPermission('projects:create') ? 'You do not have permission to create templates' : undefined}
           >
           >
             {createTemplateMutation.isPending ? (
             {createTemplateMutation.isPending ? (
               <Loader2 className="w-4 h-4 animate-spin mr-2" />
               <Loader2 className="w-4 h-4 animate-spin mr-2" />

+ 41 - 10
frontend/src/pages/ProjectsPage.tsx

@@ -20,10 +20,11 @@ import {
   Upload,
   Upload,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport } from '../api/client';
+import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 
 const PROJECT_COLORS = [
 const PROJECT_COLORS = [
   '#ef4444', // red
   '#ef4444', // red
@@ -244,9 +245,10 @@ interface ProjectCardProps {
   onClick: () => void;
   onClick: () => void;
   onEdit: () => void;
   onEdit: () => void;
   onDelete: () => void;
   onDelete: () => void;
+  hasPermission: (permission: Permission) => boolean;
 }
 }
 
 
-function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
+function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: ProjectCardProps) {
   // Plates progress: archive_count / target_count
   // Plates progress: archive_count / target_count
   const platesProgressPercent = project.target_count
   const platesProgressPercent = project.target_count
     ? Math.round((project.archive_count / project.target_count) * 100)
     ? Math.round((project.archive_count / project.target_count) * 100)
@@ -389,15 +391,23 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
                 <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
                 <div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
                 <div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                 <div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
                   <button
                   <button
-                    className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
-                    onClick={() => { onEdit(); setShowActions(false); }}
+                    className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
+                      hasPermission('projects:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    }`}
+                    onClick={() => { if (hasPermission('projects:update')) { onEdit(); setShowActions(false); } }}
+                    disabled={!hasPermission('projects:update')}
+                    title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
                   >
                   >
                     <Edit3 className="w-4 h-4" />
                     <Edit3 className="w-4 h-4" />
                     Edit
                     Edit
                   </button>
                   </button>
                   <button
                   <button
-                    className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
-                    onClick={() => { onDelete(); setShowActions(false); }}
+                    className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
+                      hasPermission('projects:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
+                    }`}
+                    onClick={() => { if (hasPermission('projects:delete')) { onDelete(); setShowActions(false); } }}
+                    disabled={!hasPermission('projects:delete')}
+                    title={!hasPermission('projects:delete') ? 'You do not have permission to delete projects' : undefined}
                   >
                   >
                     <Trash2 className="w-4 h-4" />
                     <Trash2 className="w-4 h-4" />
                     Delete
                     Delete
@@ -565,6 +575,7 @@ export function ProjectsPage() {
   const navigate = useNavigate();
   const navigate = useNavigate();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [showModal, setShowModal] = useState(false);
   const [showModal, setShowModal] = useState(false);
   const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
   const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
   const [statusFilter, setStatusFilter] = useState<string>('active');
   const [statusFilter, setStatusFilter] = useState<string>('active');
@@ -763,15 +774,30 @@ export function ProjectsPage() {
           </p>
           </p>
         </div>
         </div>
         <div className="flex gap-2">
         <div className="flex gap-2">
-          <Button variant="secondary" onClick={handleImportClick} title="Import project">
+          <Button
+            variant="secondary"
+            onClick={handleImportClick}
+            disabled={!hasPermission('projects:create')}
+            title={!hasPermission('projects:create') ? 'You do not have permission to import projects' : 'Import project'}
+          >
             <Upload className="w-4 h-4 mr-2" />
             <Upload className="w-4 h-4 mr-2" />
             Import
             Import
           </Button>
           </Button>
-          <Button variant="secondary" onClick={handleExportAll} title="Export all projects">
+          <Button
+            variant="secondary"
+            onClick={handleExportAll}
+            disabled={!hasPermission('projects:read')}
+            title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export all projects'}
+          >
             <Download className="w-4 h-4 mr-2" />
             <Download className="w-4 h-4 mr-2" />
             Export
             Export
           </Button>
           </Button>
-          <Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
+          <Button
+            onClick={() => setShowModal(true)}
+            className="sm:w-auto w-full"
+            disabled={!hasPermission('projects:create')}
+            title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
+          >
             <Plus className="w-4 h-4 mr-2" />
             <Plus className="w-4 h-4 mr-2" />
             New Project
             New Project
           </Button>
           </Button>
@@ -831,7 +857,11 @@ export function ProjectsPage() {
             }
             }
           </p>
           </p>
           {statusFilter === 'all' && (
           {statusFilter === 'all' && (
-            <Button onClick={() => setShowModal(true)}>
+            <Button
+              onClick={() => setShowModal(true)}
+              disabled={!hasPermission('projects:create')}
+              title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
+            >
               <Plus className="w-4 h-4 mr-2" />
               <Plus className="w-4 h-4 mr-2" />
               Create Your First Project
               Create Your First Project
             </Button>
             </Button>
@@ -846,6 +876,7 @@ export function ProjectsPage() {
               onClick={() => handleClick(project)}
               onClick={() => handleClick(project)}
               onEdit={() => handleEdit(project)}
               onEdit={() => handleEdit(project)}
               onDelete={() => handleDeleteClick(project.id)}
               onDelete={() => handleDeleteClick(project.id)}
+              hasPermission={hasPermission}
             />
             />
           ))}
           ))}
         </div>
         </div>

+ 28 - 9
frontend/src/pages/QueuePage.tsx

@@ -47,12 +47,13 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
 import { parseUTCDate, formatDateTime, type TimeFormat } from '../utils/date';
-import type { PrintQueueItem, PrintQueueBulkUpdate } from '../api/client';
+import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 
 function formatDuration(seconds: number | null | undefined): string {
 function formatDuration(seconds: number | null | undefined): string {
   if (!seconds) return '--';
   if (!seconds) return '--';
@@ -277,6 +278,7 @@ function SortableQueueItem({
   timeFormat = 'system',
   timeFormat = 'system',
   isSelected = false,
   isSelected = false,
   onToggleSelect,
   onToggleSelect,
+  hasPermission,
 }: {
 }: {
   item: PrintQueueItem;
   item: PrintQueueItem;
   position?: number;
   position?: number;
@@ -289,7 +291,9 @@ function SortableQueueItem({
   timeFormat?: TimeFormat;
   timeFormat?: TimeFormat;
   isSelected?: boolean;
   isSelected?: boolean;
   onToggleSelect?: () => void;
   onToggleSelect?: () => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
 }) {
+  const canReorder = hasPermission('queue:reorder');
   const {
   const {
     attributes,
     attributes,
     listeners,
     listeners,
@@ -297,7 +301,7 @@ function SortableQueueItem({
     transform,
     transform,
     transition,
     transition,
     isDragging,
     isDragging,
-  } = useSortable({ id: item.id, disabled: item.status !== 'pending' });
+  } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder });
 
 
   const style = {
   const style = {
     transform: CSS.Transform.toString(transform),
     transform: CSS.Transform.toString(transform),
@@ -489,7 +493,8 @@ function SortableQueueItem({
               variant="ghost"
               variant="ghost"
               size="sm"
               size="sm"
               onClick={onStop}
               onClick={onStop}
-              title="Stop Print"
+              disabled={!hasPermission('printers:control')}
+              title={!hasPermission('printers:control') ? 'You do not have permission to stop prints' : 'Stop Print'}
               className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
               className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
             >
             >
               <StopCircle className="w-4 h-4" />
               <StopCircle className="w-4 h-4" />
@@ -502,7 +507,8 @@ function SortableQueueItem({
                   variant="ghost"
                   variant="ghost"
                   size="sm"
                   size="sm"
                   onClick={onStart}
                   onClick={onStart}
-                  title="Start Print"
+                  disabled={!hasPermission('printers:control')}
+                  title={!hasPermission('printers:control') ? 'You do not have permission to start prints' : 'Start Print'}
                   className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
                   className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
                 >
                 >
                   <Play className="w-4 h-4" />
                   <Play className="w-4 h-4" />
@@ -512,7 +518,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onEdit}
                 onClick={onEdit}
-                title="Edit"
+                disabled={!hasPermission('queue:update')}
+                title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : 'Edit'}
               >
               >
                 <Pencil className="w-4 h-4" />
                 <Pencil className="w-4 h-4" />
               </Button>
               </Button>
@@ -520,7 +527,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onCancel}
                 onClick={onCancel}
-                title="Cancel"
+                disabled={!hasPermission('queue:delete')}
+                title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : 'Cancel'}
                 className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
                 className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
               >
               >
                 <X className="w-4 h-4" />
                 <X className="w-4 h-4" />
@@ -533,7 +541,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onRequeue}
                 onClick={onRequeue}
-                title="Re-queue"
+                disabled={!hasPermission('queue:create')}
+                title={!hasPermission('queue:create') ? 'You do not have permission to re-queue items' : 'Re-queue'}
                 className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
                 className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
               >
               >
                 <RefreshCw className="w-4 h-4" />
                 <RefreshCw className="w-4 h-4" />
@@ -542,7 +551,8 @@ function SortableQueueItem({
                 variant="ghost"
                 variant="ghost"
                 size="sm"
                 size="sm"
                 onClick={onRemove}
                 onClick={onRemove}
-                title="Remove"
+                disabled={!hasPermission('queue:delete')}
+                title={!hasPermission('queue:delete') ? 'You do not have permission to remove queue items' : 'Remove'}
               >
               >
                 <Trash2 className="w-4 h-4" />
                 <Trash2 className="w-4 h-4" />
               </Button>
               </Button>
@@ -557,6 +567,7 @@ function SortableQueueItem({
 export function QueuePage() {
 export function QueuePage() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
@@ -910,6 +921,8 @@ export function QueuePage() {
             variant="secondary"
             variant="secondary"
             size="sm"
             size="sm"
             onClick={() => setShowClearHistoryConfirm(true)}
             onClick={() => setShowClearHistoryConfirm(true)}
+            disabled={!hasPermission('queue:delete')}
+            title={!hasPermission('queue:delete') ? 'You do not have permission to clear history' : undefined}
           >
           >
             <Trash2 className="w-4 h-4" />
             <Trash2 className="w-4 h-4" />
             Clear History
             Clear History
@@ -949,6 +962,7 @@ export function QueuePage() {
                     onRequeue={() => {}}
                     onRequeue={() => {}}
                     onStart={() => {}}
                     onStart={() => {}}
                     timeFormat={timeFormat}
                     timeFormat={timeFormat}
+                    hasPermission={hasPermission}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>
@@ -1018,6 +1032,8 @@ export function QueuePage() {
                       size="sm"
                       size="sm"
                       onClick={() => setShowBulkEditModal(true)}
                       onClick={() => setShowBulkEditModal(true)}
                       className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
                       className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
+                      disabled={!hasPermission('queue:update')}
+                      title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : undefined}
                     >
                     >
                       <Pencil className="w-4 h-4" />
                       <Pencil className="w-4 h-4" />
                       Edit Selected
                       Edit Selected
@@ -1027,7 +1043,8 @@ export function QueuePage() {
                       size="sm"
                       size="sm"
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       className="flex items-center gap-2 text-red-400 hover:text-red-300"
                       className="flex items-center gap-2 text-red-400 hover:text-red-300"
-                      disabled={bulkCancelMutation.isPending}
+                      disabled={bulkCancelMutation.isPending || !hasPermission('queue:delete')}
+                      title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : undefined}
                     >
                     >
                       <X className="w-4 h-4" />
                       <X className="w-4 h-4" />
                       Cancel Selected
                       Cancel Selected
@@ -1060,6 +1077,7 @@ export function QueuePage() {
                         timeFormat={timeFormat}
                         timeFormat={timeFormat}
                         isSelected={selectedItems.includes(item.id)}
                         isSelected={selectedItems.includes(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
+                        hasPermission={hasPermission}
                       />
                       />
                     ))}
                     ))}
                   </div>
                   </div>
@@ -1113,6 +1131,7 @@ export function QueuePage() {
                     onRequeue={() => setRequeueItem(item)}
                     onRequeue={() => setRequeueItem(item)}
                     onStart={() => {}}
                     onStart={() => {}}
                     timeFormat={timeFormat}
                     timeFormat={timeFormat}
+                    hasPermission={hasPermission}
                   />
                   />
                 ))}
                 ))}
               </div>
               </div>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1008 - 170
frontend/src/pages/SettingsPage.tsx


+ 6 - 2
frontend/src/pages/StatsPage.tsx

@@ -20,6 +20,7 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { FilamentTrends } from '../components/FilamentTrends';
 import { FilamentTrends } from '../components/FilamentTrends';
@@ -505,6 +506,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
 
 
 export function StatsPage() {
 export function StatsPage() {
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [isExporting, setIsExporting] = useState(false);
   const [isExporting, setIsExporting] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [dashboardKey, setDashboardKey] = useState(0);
   const [dashboardKey, setDashboardKey] = useState(0);
@@ -683,6 +685,8 @@ export function StatsPage() {
               setDashboardKey(prev => prev + 1);
               setDashboardKey(prev => prev + 1);
               showToast('Layout reset');
               showToast('Layout reset');
             }}
             }}
+            disabled={!hasPermission('settings:update')}
+            title={!hasPermission('settings:update') ? 'You do not have permission to reset layout' : undefined}
           >
           >
             <RotateCcw className="w-4 h-4" />
             <RotateCcw className="w-4 h-4" />
             Reset Layout
             Reset Layout
@@ -691,8 +695,8 @@ export function StatsPage() {
           <Button
           <Button
             variant="secondary"
             variant="secondary"
             onClick={handleRecalculateCosts}
             onClick={handleRecalculateCosts}
-            disabled={isRecalculating}
-            title="Recalculate all archive costs using current filament prices"
+            disabled={isRecalculating || !hasPermission('archives:update')}
+            title={!hasPermission('archives:update') ? 'You do not have permission to recalculate costs' : 'Recalculate all archive costs using current filament prices'}
           >
           >
             {isRecalculating ? (
             {isRecalculating ? (
               <Loader2 className="w-4 h-4 animate-spin" />
               <Loader2 className="w-4 h-4 animate-spin" />

+ 310 - 101
frontend/src/pages/UsersPage.tsx

@@ -3,50 +3,73 @@ import { useNavigate } from 'react-router-dom';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
 import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import type { UserCreate, UserUpdate } from '../api/client';
+import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ConfirmModal } from '../components/ConfirmModal';
 
 
+interface FormData extends UserCreate {
+  group_ids: number[];
+  confirmPassword: string;
+}
+
 export function UsersPage() {
 export function UsersPage() {
   const navigate = useNavigate();
   const navigate = useNavigate();
-  const { user: currentUser } = useAuth();
+  const { user: currentUser, hasPermission } = useAuth();
   const { showToast } = useToast();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const [showCreateModal, setShowCreateModal] = useState(false);
   const [showCreateModal, setShowCreateModal] = useState(false);
-  const [editingUser, setEditingUser] = useState<number | null>(null);
+  const [showEditModal, setShowEditModal] = useState(false);
+  const [editingUserId, setEditingUserId] = useState<number | null>(null);
   const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
   const [deleteUserId, setDeleteUserId] = useState<number | null>(null);
-  const [formData, setFormData] = useState<UserCreate>({
+  const [formData, setFormData] = useState<FormData>({
     username: '',
     username: '',
     password: '',
     password: '',
+    confirmPassword: '',
     role: 'user',
     role: 'user',
+    group_ids: [],
   });
   });
 
 
   // Close modal on Escape key
   // Close modal on Escape key
   useEffect(() => {
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
     const handleKeyDown = (e: KeyboardEvent) => {
-      if (e.key === 'Escape' && showCreateModal) {
-        setShowCreateModal(false);
-        setFormData({ username: '', password: '', role: 'user' });
+      if (e.key === 'Escape') {
+        if (showCreateModal) {
+          setShowCreateModal(false);
+          setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
+        }
+        if (showEditModal) {
+          setShowEditModal(false);
+          setEditingUserId(null);
+          setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
+        }
       }
       }
     };
     };
     window.addEventListener('keydown', handleKeyDown);
     window.addEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [showCreateModal]);
+  }, [showCreateModal, showEditModal]);
 
 
   const { data: users = [], isLoading } = useQuery({
   const { data: users = [], isLoading } = useQuery({
     queryKey: ['users'],
     queryKey: ['users'],
     queryFn: () => api.getUsers(),
     queryFn: () => api.getUsers(),
+    enabled: hasPermission('users:read'),
+  });
+
+  const { data: groups = [] } = useQuery({
+    queryKey: ['groups'],
+    queryFn: () => api.getGroups(),
+    enabled: hasPermission('groups:read'),
   });
   });
 
 
   const createMutation = useMutation({
   const createMutation = useMutation({
     mutationFn: (data: UserCreate) => api.createUser(data),
     mutationFn: (data: UserCreate) => api.createUser(data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['users'] });
       queryClient.invalidateQueries({ queryKey: ['users'] });
+      queryClient.invalidateQueries({ queryKey: ['groups'] });
       setShowCreateModal(false);
       setShowCreateModal(false);
-      setFormData({ username: '', password: '', role: 'user' });
+      setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
       showToast('User created successfully');
       showToast('User created successfully');
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
@@ -58,8 +81,10 @@ export function UsersPage() {
     mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
     mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
     onSuccess: () => {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['users'] });
       queryClient.invalidateQueries({ queryKey: ['users'] });
-      setEditingUser(null);
-      setFormData({ username: '', password: '', role: 'user' });
+      queryClient.invalidateQueries({ queryKey: ['groups'] });
+      setShowEditModal(false);
+      setEditingUserId(null);
+      setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
       showToast('User updated successfully');
       showToast('User updated successfully');
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
@@ -83,14 +108,39 @@ export function UsersPage() {
       showToast('Please fill in all required fields', 'error');
       showToast('Please fill in all required fields', 'error');
       return;
       return;
     }
     }
-    createMutation.mutate(formData);
+    if (formData.password !== formData.confirmPassword) {
+      showToast('Passwords do not match', 'error');
+      return;
+    }
+    if (formData.password.length < 6) {
+      showToast('Password must be at least 6 characters', 'error');
+      return;
+    }
+    createMutation.mutate({
+      username: formData.username,
+      password: formData.password,
+      role: formData.role,
+      group_ids: formData.group_ids.length > 0 ? formData.group_ids : undefined,
+    });
   };
   };
 
 
   const handleUpdate = (id: number) => {
   const handleUpdate = (id: number) => {
+    // Validate password confirmation if a new password is being set
+    if (formData.password) {
+      if (formData.password !== formData.confirmPassword) {
+        showToast('Passwords do not match', 'error');
+        return;
+      }
+      if (formData.password.length < 6) {
+        showToast('Password must be at least 6 characters', 'error');
+        return;
+      }
+    }
     const updateData: UserUpdate = {
     const updateData: UserUpdate = {
       username: formData.username || undefined,
       username: formData.username || undefined,
       password: formData.password || undefined,
       password: formData.password || undefined,
       role: formData.role,
       role: formData.role,
+      group_ids: formData.group_ids,
     };
     };
     // Remove password if empty
     // Remove password if empty
     if (!updateData.password) {
     if (!updateData.password) {
@@ -103,16 +153,34 @@ export function UsersPage() {
     setDeleteUserId(id);
     setDeleteUserId(id);
   };
   };
 
 
-  const startEdit = (user: { id: number; username: string; role: string }) => {
-    setEditingUser(user.id);
+  const startEdit = (user: UserResponse) => {
+    setEditingUserId(user.id);
     setFormData({
     setFormData({
       username: user.username,
       username: user.username,
       password: '',
       password: '',
+      confirmPassword: '',
       role: user.role,
       role: user.role,
+      group_ids: user.groups?.map(g => g.id) || [],
     });
     });
+    setShowEditModal(true);
+  };
+
+  const closeEditModal = () => {
+    setShowEditModal(false);
+    setEditingUserId(null);
+    setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
   };
   };
 
 
-  if (currentUser?.role !== 'admin') {
+  const toggleGroup = (groupId: number) => {
+    setFormData(prev => ({
+      ...prev,
+      group_ids: prev.group_ids.includes(groupId)
+        ? prev.group_ids.filter(id => id !== groupId)
+        : [...prev.group_ids, groupId],
+    }));
+  };
+
+  if (!hasPermission('users:read')) {
     return (
     return (
       <div className="p-6">
       <div className="p-6">
         <Card>
         <Card>
@@ -151,7 +219,7 @@ export function UsersPage() {
         <Button
         <Button
           onClick={() => {
           onClick={() => {
             setShowCreateModal(true);
             setShowCreateModal(true);
-            setFormData({ username: '', password: '', role: 'user' });
+            setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
           }}
           }}
         >
         >
           <Plus className="w-4 h-4" />
           <Plus className="w-4 h-4" />
@@ -173,7 +241,7 @@ export function UsersPage() {
                     Username
                     Username
                   </th>
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    Role
+                    Groups
                   </th>
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
                     Status
                     Status
@@ -187,36 +255,35 @@ export function UsersPage() {
                 {users.map((user) => (
                 {users.map((user) => (
                   <tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
                   <tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
-                      {editingUser === user.id ? (
-                        <input
-                          type="text"
-                          value={formData.username}
-                          onChange={(e) => setFormData({ ...formData, username: e.target.value })}
-                          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green"
-                        />
-                      ) : (
-                        user.username
-                      )}
+                      {user.username}
                     </td>
                     </td>
-                    <td className="px-6 py-4 whitespace-nowrap text-sm">
-                      {editingUser === user.id ? (
-                        <select
-                          value={formData.role}
-                          onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
-                          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green"
-                        >
-                          <option value="user">User</option>
-                          <option value="admin">Admin</option>
-                        </select>
-                      ) : (
-                        <span className={`px-3 py-1 rounded-full text-xs font-medium ${
-                          user.role === 'admin'
-                            ? 'bg-purple-500/20 text-purple-300'
-                            : 'bg-blue-500/20 text-blue-300'
-                        }`}>
-                          {user.role}
-                        </span>
-                      )}
+                    <td className="px-6 py-4 text-sm">
+                      <div className="flex flex-wrap gap-1">
+                        {user.is_admin && (
+                          <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300">
+                            Admin
+                          </span>
+                        )}
+                        {user.groups?.map(group => (
+                          <span
+                            key={group.id}
+                            className={`px-2 py-0.5 rounded-full text-xs font-medium ${
+                              group.name === 'Administrators'
+                                ? 'bg-purple-500/20 text-purple-300'
+                                : group.name === 'Operators'
+                                ? 'bg-blue-500/20 text-blue-300'
+                                : group.name === 'Viewers'
+                                ? 'bg-green-500/20 text-green-300'
+                                : 'bg-gray-500/20 text-gray-300'
+                            }`}
+                          >
+                            {group.name}
+                          </span>
+                        ))}
+                        {(!user.groups || user.groups.length === 0) && !user.is_admin && (
+                          <span className="text-bambu-gray">No groups</span>
+                        )}
+                      </div>
                     </td>
                     </td>
                     <td className="px-6 py-4 whitespace-nowrap text-sm">
                     <td className="px-6 py-4 whitespace-nowrap text-sm">
                       <span className={`px-3 py-1 rounded-full text-xs font-medium ${
                       <span className={`px-3 py-1 rounded-full text-xs font-medium ${
@@ -228,53 +295,26 @@ export function UsersPage() {
                       </span>
                       </span>
                     </td>
                     </td>
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
-                      {editingUser === user.id ? (
-                        <div className="flex items-center gap-2">
-                          <Button
-                            size="sm"
-                            onClick={() => handleUpdate(user.id)}
-                            disabled={updateMutation.isPending}
-                          >
-                            {updateMutation.isPending ? (
-                              <Loader2 className="w-4 h-4 animate-spin" />
-                            ) : (
-                              <Save className="w-4 h-4" />
-                            )}
-                            Save
-                          </Button>
-                          <Button
-                            size="sm"
-                            variant="secondary"
-                            onClick={() => {
-                              setEditingUser(null);
-                              setFormData({ username: '', password: '', role: 'user' });
-                            }}
-                          >
-                            Cancel
-                          </Button>
-                        </div>
-                      ) : (
-                        <div className="flex items-center gap-2">
+                      <div className="flex items-center gap-2">
+                        <Button
+                          size="sm"
+                          variant="ghost"
+                          onClick={() => startEdit(user)}
+                        >
+                          <Edit2 className="w-4 h-4" />
+                          Edit
+                        </Button>
+                        {user.id !== currentUser?.id && (
                           <Button
                           <Button
                             size="sm"
                             size="sm"
                             variant="ghost"
                             variant="ghost"
-                            onClick={() => startEdit(user)}
+                            onClick={() => handleDelete(user.id)}
                           >
                           >
-                            <Edit2 className="w-4 h-4" />
-                            Edit
+                            <Trash2 className="w-4 h-4" />
+                            Delete
                           </Button>
                           </Button>
-                          {user.id !== currentUser?.id && (
-                            <Button
-                              size="sm"
-                              variant="ghost"
-                              onClick={() => handleDelete(user.id)}
-                            >
-                              <Trash2 className="w-4 h-4" />
-                              Delete
-                            </Button>
-                          )}
-                        </div>
-                      )}
+                        )}
+                      </div>
                     </td>
                     </td>
                   </tr>
                   </tr>
                 ))}
                 ))}
@@ -290,7 +330,7 @@ export function UsersPage() {
           className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
           className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
           onClick={() => {
           onClick={() => {
             setShowCreateModal(false);
             setShowCreateModal(false);
-            setFormData({ username: '', password: '', role: 'user' });
+            setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
           }}
           }}
         >
         >
           <Card
           <Card
@@ -308,7 +348,7 @@ export function UsersPage() {
                   size="sm"
                   size="sm"
                   onClick={() => {
                   onClick={() => {
                     setShowCreateModal(false);
                     setShowCreateModal(false);
-                    setFormData({ username: '', password: '', role: 'user' });
+                    setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
                   }}
                   }}
                 >
                 >
                   <X className="w-5 h-5" />
                   <X className="w-5 h-5" />
@@ -346,16 +386,51 @@ export function UsersPage() {
                 </div>
                 </div>
                 <div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
                   <label className="block text-sm font-medium text-white mb-2">
-                    Role
+                    Confirm Password
                   </label>
                   </label>
-                  <select
-                    value={formData.role}
-                    onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
-                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                  >
-                    <option value="user">User</option>
-                    <option value="admin">Admin</option>
-                  </select>
+                  <input
+                    type="password"
+                    value={formData.confirmPassword}
+                    onChange={(e) => setFormData({ ...formData, 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 ${
+                      formData.confirmPassword && formData.password !== formData.confirmPassword
+                        ? 'border-red-500'
+                        : 'border-bambu-dark-tertiary'
+                    }`}
+                    placeholder="Confirm password"
+                    autoComplete="new-password"
+                    minLength={6}
+                  />
+                  {formData.confirmPassword && formData.password !== formData.confirmPassword && (
+                    <p className="text-red-400 text-xs mt-1">Passwords do not match</p>
+                  )}
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Groups
+                  </label>
+                  <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+                    {groups.map(group => (
+                      <label
+                        key={group.id}
+                        className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
+                      >
+                        <input
+                          type="checkbox"
+                          checked={formData.group_ids.includes(group.id)}
+                          onChange={() => toggleGroup(group.id)}
+                          className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
+                        />
+                        <span className="text-sm text-white">{group.name}</span>
+                        {group.is_system && (
+                          <span className="text-xs text-yellow-400">(System)</span>
+                        )}
+                      </label>
+                    ))}
+                    {groups.length === 0 && (
+                      <p className="text-sm text-bambu-gray">No groups available</p>
+                    )}
+                  </div>
                 </div>
                 </div>
               </div>
               </div>
               <div className="mt-6 flex justify-end gap-3">
               <div className="mt-6 flex justify-end gap-3">
@@ -363,14 +438,14 @@ export function UsersPage() {
                   variant="secondary"
                   variant="secondary"
                   onClick={() => {
                   onClick={() => {
                     setShowCreateModal(false);
                     setShowCreateModal(false);
-                    setFormData({ username: '', password: '', role: 'user' });
+                    setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
                   }}
                   }}
                 >
                 >
                   Cancel
                   Cancel
                 </Button>
                 </Button>
                 <Button
                 <Button
                   onClick={handleCreate}
                   onClick={handleCreate}
-                  disabled={createMutation.isPending || !formData.username || !formData.password}
+                  disabled={createMutation.isPending || !formData.username || !formData.password || formData.password !== formData.confirmPassword || formData.password.length < 6}
                 >
                 >
                   {createMutation.isPending ? (
                   {createMutation.isPending ? (
                     <>
                     <>
@@ -390,6 +465,140 @@ export function UsersPage() {
         </div>
         </div>
       )}
       )}
 
 
+      {/* Edit User Modal */}
+      {showEditModal && editingUserId !== null && (
+        <div
+          className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+          onClick={closeEditModal}
+        >
+          <Card
+            className="w-full max-w-md"
+            onClick={(e: React.MouseEvent) => e.stopPropagation()}
+          >
+            <CardHeader>
+              <div className="flex items-center justify-between">
+                <div className="flex items-center gap-2">
+                  <Edit2 className="w-5 h-5 text-bambu-green" />
+                  <h2 className="text-lg font-semibold text-white">Edit User</h2>
+                </div>
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  onClick={closeEditModal}
+                >
+                  <X className="w-5 h-5" />
+                </Button>
+              </div>
+            </CardHeader>
+            <CardContent>
+              <div className="space-y-4">
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Username
+                  </label>
+                  <input
+                    type="text"
+                    value={formData.username}
+                    onChange={(e) => setFormData({ ...formData, username: e.target.value })}
+                    className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
+                    placeholder="Enter username"
+                    autoComplete="username"
+                  />
+                </div>
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Password <span className="text-bambu-gray font-normal">(leave blank to keep current)</span>
+                  </label>
+                  <input
+                    type="password"
+                    value={formData.password}
+                    onChange={(e) => setFormData({ ...formData, password: e.target.value, confirmPassword: '' })}
+                    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"
+                    autoComplete="new-password"
+                    minLength={6}
+                  />
+                </div>
+                {formData.password && (
+                  <div>
+                    <label className="block text-sm font-medium text-white mb-2">
+                      Confirm Password
+                    </label>
+                    <input
+                      type="password"
+                      value={formData.confirmPassword}
+                      onChange={(e) => setFormData({ ...formData, 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 ${
+                        formData.confirmPassword && formData.password !== formData.confirmPassword
+                          ? 'border-red-500'
+                          : 'border-bambu-dark-tertiary'
+                      }`}
+                      placeholder="Confirm new password"
+                      autoComplete="new-password"
+                      minLength={6}
+                    />
+                    {formData.confirmPassword && formData.password !== formData.confirmPassword && (
+                      <p className="text-red-400 text-xs mt-1">Passwords do not match</p>
+                    )}
+                  </div>
+                )}
+                <div>
+                  <label className="block text-sm font-medium text-white mb-2">
+                    Groups
+                  </label>
+                  <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
+                    {groups.map(group => (
+                      <label
+                        key={group.id}
+                        className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-tertiary cursor-pointer"
+                      >
+                        <input
+                          type="checkbox"
+                          checked={formData.group_ids.includes(group.id)}
+                          onChange={() => toggleGroup(group.id)}
+                          className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark"
+                        />
+                        <span className="text-sm text-white">{group.name}</span>
+                        {group.is_system && (
+                          <span className="text-xs text-yellow-400">(System)</span>
+                        )}
+                      </label>
+                    ))}
+                    {groups.length === 0 && (
+                      <p className="text-sm text-bambu-gray">No groups available</p>
+                    )}
+                  </div>
+                </div>
+              </div>
+              <div className="mt-6 flex justify-end gap-3">
+                <Button
+                  variant="secondary"
+                  onClick={closeEditModal}
+                >
+                  Cancel
+                </Button>
+                <Button
+                  onClick={() => handleUpdate(editingUserId)}
+                  disabled={updateMutation.isPending || !formData.username || !!(formData.password && (formData.password !== formData.confirmPassword || formData.password.length < 6))}
+                >
+                  {updateMutation.isPending ? (
+                    <>
+                      <Loader2 className="w-4 h-4 animate-spin" />
+                      Saving...
+                    </>
+                  ) : (
+                    <>
+                      <Save className="w-4 h-4" />
+                      Save Changes
+                    </>
+                  )}
+                </Button>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+
       {/* Delete Confirmation Modal */}
       {/* Delete Confirmation Modal */}
       {deleteUserId !== null && (
       {deleteUserId !== null && (
         <ConfirmModal
         <ConfirmModal

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-B0_vH-u8.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-BrclLX7E.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-Bwf4poPr.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
static/assets/index-DlQJz8pN.js


+ 2 - 2
static/index.html

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

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio