Explorar o 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 hai 3 meses
pai
achega
89229a5ecc
Modificáronse 46 ficheiros con 5110 adicións e 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
 
 ### New Features
+- **Group-Based Permissions** - Granular access control with user groups:
+  - Create custom groups with specific permissions (50+ granular permissions)
+  - Default system groups: Administrators (full access), Operators (control printers), Viewers (read-only)
+  - Users can belong to multiple groups with additive permissions
+  - Permission-based UI: buttons/features disabled when user lacks permission
+  - Groups management page in Settings → Users → Groups tab
+  - Change password: users can change their own password from sidebar
+  - Included in backup/restore
 - **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):
   - Checkbox option when uploading STL files to generate thumbnails automatically
   - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button

+ 3 - 2
README.md

@@ -152,9 +152,10 @@
 
 ### 🔒 Optional Authentication
 - Enable/disable authentication any time
-- Role-based access (Admin/User)
+- Group-based permissions (50+ granular permissions)
+- Default groups: Administrators, Operators, Viewers
 - JWT tokens with secure password hashing
-- User management (create, edit, delete)
+- User management (create, edit, delete, groups)
 
 </td>
 </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 sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 from backend.app.core.auth import (
     ACCESS_TOKEN_EXPIRE_MINUTES,
@@ -13,9 +14,25 @@ from backend.app.core.auth import (
     get_user_by_username,
 )
 from backend.app.core.database import get_db
+from backend.app.models.group import Group
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
-from backend.app.schemas.auth import LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
+from backend.app.schemas.auth import GroupBrief, LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
+
+
+def _user_to_response(user: User) -> UserResponse:
+    """Convert a User model to UserResponse schema."""
+    return UserResponse(
+        id=user.id,
+        username=user.username,
+        role=user.role,
+        is_active=user.is_active,
+        is_admin=user.is_admin,
+        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
+        permissions=sorted(user.get_permissions()),
+        created_at=user.created_at.isoformat(),
+    )
+
 
 router = APIRouter(prefix="/auth", tags=["authentication"])
 
@@ -126,6 +143,14 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
                         role="admin",
                         is_active=True,
                     )
+
+                    # Try to add user to Administrators group if it exists
+                    admin_group_result = await db.execute(select(Group).where(Group.name == "Administrators"))
+                    admin_group = admin_group_result.scalar_one_or_none()
+                    if admin_group:
+                        admin_user.groups.append(admin_group)
+                        logger.info("Added new admin user to Administrators group")
+
                     db.add(admin_user)
                     logger.info(f"Admin user added to session: {request.admin_username}")
                     admin_created = True
@@ -179,8 +204,12 @@ async def disable_auth(
 
     logger = logging.getLogger(__name__)
 
+    # Reload user with groups for proper is_admin check
+    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
     # Only admins can disable authentication
-    if current_user.role != "admin":
+    if not user.is_admin:
         raise HTTPException(
             status_code=status.HTTP_403_FORBIDDEN,
             detail="Only admins can disable authentication",
@@ -189,7 +218,7 @@ async def disable_auth(
     try:
         await set_auth_enabled(db, False)
         await db.commit()
-        logger.info(f"Authentication disabled by admin user: {current_user.username}")
+        logger.info(f"Authentication disabled by admin user: {user.username}")
         return {"message": "Authentication disabled successfully", "auth_enabled": False}
     except Exception as e:
         await db.rollback()
@@ -219,32 +248,30 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
             headers={"WWW-Authenticate": "Bearer"},
         )
 
+    # Reload user with groups for proper permission calculation
+    result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+
     access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
 
     return LoginResponse(
         access_token=access_token,
         token_type="bearer",
-        user=UserResponse(
-            id=user.id,
-            username=user.username,
-            role=user.role,
-            is_active=user.is_active,
-            created_at=user.created_at.isoformat(),
-        ),
+        user=_user_to_response(user),
     )
 
 
 @router.get("/me", response_model=UserResponse)
-async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
+async def get_current_user_info(
+    current_user: User = Depends(get_current_active_user),
+    db: AsyncSession = Depends(get_db),
+):
     """Get current user information."""
-    return UserResponse(
-        id=current_user.id,
-        username=current_user.username,
-        role=current_user.role,
-        is_active=current_user.is_active,
-        created_at=current_user.created_at.isoformat(),
-    )
+    # Reload user with groups for proper permission calculation
+    result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
+    user = result.scalar_one()
+    return _user_to_response(user)
 
 
 @router.post("/logout")

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

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

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

@@ -1,44 +1,57 @@
 from fastapi import APIRouter, Depends, HTTPException, status
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
-from backend.app.core.auth import RequireAdmin, get_password_hash
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    get_current_user_optional,
+    get_password_hash,
+    verify_password,
+)
 from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.group import Group
 from backend.app.models.user import User
-from backend.app.schemas.auth import UserCreate, UserResponse, UserUpdate
+from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
 
 router = APIRouter(prefix="/users", tags=["users"])
 
 
+def _user_to_response(user: User) -> UserResponse:
+    """Convert a User model to UserResponse schema."""
+    return UserResponse(
+        id=user.id,
+        username=user.username,
+        role=user.role,
+        is_active=user.is_active,
+        is_admin=user.is_admin,
+        groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
+        permissions=sorted(user.get_permissions()),
+        created_at=user.created_at.isoformat(),
+    )
+
+
 @router.get("", response_model=list[UserResponse])
 @router.get("/", response_model=list[UserResponse])
 async def list_users(
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """List all users (admin only)."""
-    result = await db.execute(select(User).order_by(User.created_at))
+    """List all users."""
+    result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
     users = result.scalars().all()
-    return [
-        UserResponse(
-            id=user.id,
-            username=user.username,
-            role=user.role,
-            is_active=user.is_active,
-            created_at=user.created_at.isoformat(),
-        )
-        for user in users
-    ]
+    return [_user_to_response(user) for user in users]
 
 
 @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
 async def create_user(
     user_data: UserCreate,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Create a new user (admin only)."""
+    """Create a new user."""
     # Check if username already exists
     existing_user = await db.execute(select(User).where(User.username == user_data.username))
     if existing_user.scalar_one_or_none():
@@ -60,27 +73,33 @@ async def create_user(
         role=user_data.role,
         is_active=True,
     )
+
+    # Handle group assignments
+    if user_data.group_ids:
+        groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
+        groups = groups_result.scalars().all()
+        if len(groups) != len(user_data.group_ids):
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="One or more group IDs are invalid",
+            )
+        new_user.groups = list(groups)
+
     db.add(new_user)
     await db.commit()
     await db.refresh(new_user)
 
-    return UserResponse(
-        id=new_user.id,
-        username=new_user.username,
-        role=new_user.role,
-        is_active=new_user.is_active,
-        created_at=new_user.created_at.isoformat(),
-    )
+    return _user_to_response(new_user)
 
 
 @router.get("/{user_id}", response_model=UserResponse)
 async def get_user(
     user_id: int,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """Get a user by ID (admin only)."""
-    result = await db.execute(select(User).where(User.id == user_id))
+    """Get a user by ID."""
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     if not user:
         raise HTTPException(
@@ -88,24 +107,18 @@ async def get_user(
             detail="User not found",
         )
 
-    return UserResponse(
-        id=user.id,
-        username=user.username,
-        role=user.role,
-        is_active=user.is_active,
-        created_at=user.created_at.isoformat(),
-    )
+    return _user_to_response(user)
 
 
 @router.patch("/{user_id}", response_model=UserResponse)
 async def update_user(
     user_id: int,
     user_data: UserUpdate,
-    current_user: User = RequireAdmin(),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Update a user (admin only)."""
-    result = await db.execute(select(User).where(User.id == user_id))
+    """Update a user."""
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     if not user:
         raise HTTPException(
@@ -114,10 +127,21 @@ async def update_user(
         )
 
     # Prevent deactivating the last admin
-    if user_data.is_active is False and user.role == "admin":
+    if user_data.is_active is False and user.is_admin:
+        # Count admins by role or Administrators group membership
         admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
-        admin_count = len(admin_count_result.scalars().all())
-        if admin_count <= 1:
+        role_admins = admin_count_result.scalars().all()
+
+        # Also check for users in Administrators group
+        admin_group_result = await db.execute(
+            select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
+        )
+        admin_group = admin_group_result.scalar_one_or_none()
+        group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active]
+
+        # Combine unique admins
+        all_admins = {u.id for u in role_admins} | {u.id for u in group_admins}
+        if len(all_admins) <= 1 and user.id in all_admins:
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Cannot deactivate the last admin user",
@@ -157,26 +181,31 @@ async def update_user(
     if user_data.is_active is not None:
         user.is_active = user_data.is_active
 
+    # Handle group assignments
+    if user_data.group_ids is not None:
+        groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
+        groups = groups_result.scalars().all()
+        if len(groups) != len(user_data.group_ids):
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail="One or more group IDs are invalid",
+            )
+        user.groups = list(groups)
+
     await db.commit()
     await db.refresh(user)
 
-    return UserResponse(
-        id=user.id,
-        username=user.username,
-        role=user.role,
-        is_active=user.is_active,
-        created_at=user.created_at.isoformat(),
-    )
+    return _user_to_response(user)
 
 
 @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
 async def delete_user(
     user_id: int,
-    current_user: User = RequireAdmin(),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Delete a user (admin only)."""
-    result = await db.execute(select(User).where(User.id == user_id))
+    """Delete a user."""
+    result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
     user = result.scalar_one_or_none()
     if not user:
         raise HTTPException(
@@ -185,17 +214,28 @@ async def delete_user(
         )
 
     # Prevent deleting the last admin
-    if user.role == "admin":
+    if user.is_admin:
+        # Count admins by role or Administrators group membership
         admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
-        admin_count = len(admin_count_result.scalars().all())
-        if admin_count == 0:
+        other_role_admins = admin_count_result.scalars().all()
+
+        # Also check for users in Administrators group
+        admin_group_result = await db.execute(
+            select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
+        )
+        admin_group = admin_group_result.scalar_one_or_none()
+        other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active]
+
+        # Combine unique admins
+        all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins}
+        if len(all_other_admins) == 0:
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail="Cannot delete the last admin user",
             )
 
-    # Prevent deleting yourself
-    if user.id == current_user.id:
+    # Prevent deleting yourself (only if auth is enabled and we have a current user)
+    if current_user and user.id == current_user.id:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             detail="Cannot delete your own account",
@@ -203,3 +243,46 @@ async def delete_user(
 
     await db.delete(user)
     await db.commit()
+
+
+@router.post("/me/change-password", response_model=dict)
+async def change_own_password(
+    password_data: ChangePasswordRequest,
+    current_user: User | None = Depends(get_current_user_optional),
+    db: AsyncSession = Depends(get_db),
+):
+    """Change the current user's password. Requires current password verification."""
+    if not current_user:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Authentication required to change password",
+        )
+
+    # Verify current password
+    if not verify_password(password_data.current_password, current_user.password_hash):
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Current password is incorrect",
+        )
+
+    # Validate new password
+    if len(password_data.new_password) < 6:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="New password must be at least 6 characters",
+        )
+
+    # Fetch user from this session to ensure changes are persisted
+    result = await db.execute(select(User).where(User.id == current_user.id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found",
+        )
+
+    # Update password
+    user.password_hash = get_password_hash(password_data.new_password)
+    await db.commit()
+
+    return {"message": "Password changed successfully"}

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

@@ -11,8 +11,10 @@ from jwt.exceptions import PyJWTError as JWTError
 from passlib.context import CryptContext
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
 
 from backend.app.core.database import async_session, get_db
+from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
@@ -60,8 +62,8 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
 
 
 async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
-    """Get a user by username."""
-    result = await db.execute(select(User).where(User.username == username))
+    """Get a user by username with groups loaded for permission checks."""
+    result = await db.execute(select(User).where(User.username == username).options(selectinload(User.groups)))
     return result.scalar_one_or_none()
 
 
@@ -347,3 +349,124 @@ def RequireAdmin():
 def RequireAdminIfAuthEnabled():
     """Dependency that requires admin role if auth is enabled."""
     return Depends(require_admin_if_auth_enabled())
+
+
+def require_permission(*permissions: str | Permission):
+    """Dependency factory that requires user to have ALL specified permissions.
+
+    Args:
+        *permissions: Permission strings or Permission enum values to require
+
+    Returns:
+        A dependency function that validates permissions
+    """
+    # Convert Permission enums to strings
+    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
+
+    async def permission_checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    ) -> User:
+        credentials_exception = HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Could not validate credentials",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+        if credentials is None:
+            raise credentials_exception
+
+        try:
+            token = credentials.credentials
+            payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+            username: str = payload.get("sub")
+            if username is None:
+                raise credentials_exception
+        except JWTError:
+            raise credentials_exception
+
+        async with async_session() as db:
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise credentials_exception
+
+            if not user.has_all_permissions(*perm_strings):
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing required permissions: {', '.join(perm_strings)}",
+                )
+            return user
+
+    return permission_checker
+
+
+def require_permission_if_auth_enabled(*permissions: str | Permission):
+    """Dependency factory that checks permissions only if auth is enabled.
+
+    This provides backward compatibility - when auth is disabled, all access is allowed.
+
+    Args:
+        *permissions: Permission strings or Permission enum values to require
+
+    Returns:
+        A dependency function that validates permissions if auth is enabled
+    """
+    # Convert Permission enums to strings
+    perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
+
+    async def permission_checker(
+        credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    ) -> User | None:
+        async with async_session() as db:
+            auth_enabled = await is_auth_enabled(db)
+            if not auth_enabled:
+                return None  # Auth disabled, allow access
+
+            if credentials is None:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Authentication required",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            try:
+                token = credentials.credentials
+                payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+                username: str = payload.get("sub")
+                if username is None:
+                    raise HTTPException(
+                        status_code=status.HTTP_401_UNAUTHORIZED,
+                        detail="Could not validate credentials",
+                        headers={"WWW-Authenticate": "Bearer"},
+                    )
+            except JWTError:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            user = await get_user_by_username(db, username)
+            if user is None or not user.is_active:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Could not validate credentials",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+
+            if not user.has_all_permissions(*perm_strings):
+                raise HTTPException(
+                    status_code=status.HTTP_403_FORBIDDEN,
+                    detail=f"Missing required permissions: {', '.join(perm_strings)}",
+                )
+            return user
+
+    return permission_checker
+
+
+def RequirePermission(*permissions: str | Permission):
+    """Convenience dependency that requires ALL specified permissions."""
+    return Depends(require_permission(*permissions))
+
+
+def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
+    """Convenience dependency that requires permissions if auth is enabled."""
+    return Depends(require_permission_if_auth_enabled(*permissions))

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

@@ -39,6 +39,7 @@ async def init_db():
         external_link,
         filament,
         github_backup,
+        group,
         kprofile_note,
         library,
         maintenance,
@@ -62,6 +63,9 @@ async def init_db():
     # Seed default notification templates
     await seed_notification_templates()
 
+    # Seed default groups and migrate existing users
+    await seed_default_groups()
+
 
 async def run_migrations(conn):
     """Add new columns to existing tables if they don't exist."""
@@ -800,6 +804,41 @@ async def run_migrations(conn):
         except Exception:
             pass
 
+    # Migration: Create groups table for permission-based access control
+    try:
+        await conn.execute(
+            text("""
+            CREATE TABLE IF NOT EXISTS groups (
+                id INTEGER PRIMARY KEY,
+                name VARCHAR(100) NOT NULL UNIQUE,
+                description VARCHAR(500),
+                permissions JSON,
+                is_system BOOLEAN NOT NULL DEFAULT 0,
+                created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+            )
+        """)
+        )
+        await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)"))
+    except Exception:
+        pass
+
+    # Migration: Create user_groups association table
+    try:
+        await conn.execute(
+            text("""
+            CREATE TABLE IF NOT EXISTS user_groups (
+                user_id INTEGER NOT NULL,
+                group_id INTEGER NOT NULL,
+                PRIMARY KEY (user_id, group_id),
+                FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+                FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
+            )
+        """)
+        )
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""
@@ -837,3 +876,70 @@ async def seed_notification_templates():
                     session.add(template)
 
         await session.commit()
+
+
+async def seed_default_groups():
+    """Seed default groups and migrate existing users to appropriate groups.
+
+    Creates the default system groups (Administrators, Operators, Viewers) if they
+    don't exist, then migrates existing users:
+    - Users with role='admin' -> Administrators group
+    - Users with role='user' -> Operators group
+    """
+    import logging
+
+    from sqlalchemy import select
+
+    from backend.app.core.permissions import DEFAULT_GROUPS
+    from backend.app.models.group import Group
+    from backend.app.models.user import User
+
+    logger = logging.getLogger(__name__)
+
+    async with async_session() as session:
+        # Get existing groups
+        result = await session.execute(select(Group.name))
+        existing_groups = {row[0] for row in result.fetchall()}
+
+        # Create default groups if they don't exist
+        groups_created = []
+        for group_name, group_config in DEFAULT_GROUPS.items():
+            if group_name not in existing_groups:
+                group = Group(
+                    name=group_name,
+                    description=group_config["description"],
+                    permissions=group_config["permissions"],
+                    is_system=group_config["is_system"],
+                )
+                session.add(group)
+                groups_created.append(group_name)
+                logger.info(f"Created default group: {group_name}")
+
+        await session.commit()
+
+        # Migrate existing users to groups if they're not already in any group
+        if groups_created:
+            # Get the groups we need
+            admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
+            admin_group = admin_result.scalar_one_or_none()
+
+            operators_result = await session.execute(select(Group).where(Group.name == "Operators"))
+            operators_group = operators_result.scalar_one_or_none()
+
+            # Get all users
+            users_result = await session.execute(select(User))
+            users = users_result.scalars().all()
+
+            for user in users:
+                # Skip if user already has groups
+                if user.groups:
+                    continue
+
+                if user.role == "admin" and admin_group:
+                    user.groups.append(admin_group)
+                    logger.info(f"Migrated admin user '{user.username}' to Administrators group")
+                elif operators_group:
+                    user.groups.append(operators_group)
+                    logger.info(f"Migrated user '{user.username}' to Operators group")
+
+            await session.commit()

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

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

+ 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 typing import TYPE_CHECKING
 
 from sqlalchemy import DateTime, String, func
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
 
+if TYPE_CHECKING:
+    from backend.app.models.group import Group
+
 
 class User(Base):
-    """User model for authentication and authorization."""
+    """User model for authentication and authorization.
+
+    Users can belong to multiple groups, and their permissions are additive
+    across all groups. The legacy 'role' field is kept for backward compatibility
+    but is_admin property now also considers group membership.
+    """
 
     __tablename__ = "users"
 
     id: Mapped[int] = mapped_column(primary_key=True)
     username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
     password_hash: Mapped[str] = mapped_column(String(255))
-    role: Mapped[str] = mapped_column(String(20), default="user")  # "admin" or "user"
+    role: Mapped[str] = mapped_column(
+        String(20), default="user"
+    )  # "admin" or "user" (legacy, kept for backward compat)
     is_active: Mapped[bool] = mapped_column(default=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    # Relationship to groups through association table
+    groups: Mapped[list[Group]] = relationship(
+        "Group",
+        secondary="user_groups",
+        back_populates="users",
+        lazy="selectin",
+    )
+
+    @property
+    def is_admin(self) -> bool:
+        """Check if user is an admin.
+
+        Returns True if:
+        - User has legacy role='admin', OR
+        - User belongs to the Administrators group
+        """
+        if self.role == "admin":
+            return True
+        return any(g.name == "Administrators" for g in self.groups)
+
+    def get_permissions(self) -> set[str]:
+        """Get all permissions from all groups the user belongs to.
+
+        Returns a set of permission strings. Permissions are additive across groups.
+        """
+        permissions: set[str] = set()
+        for group in self.groups:
+            if group.permissions:
+                permissions.update(group.permissions)
+        return permissions
+
+    def has_permission(self, permission: str) -> bool:
+        """Check if user has a specific permission.
+
+        Admins have all permissions. For other users, checks if the permission
+        exists in any of their groups.
+        """
+        if self.is_admin:
+            return True
+        return permission in self.get_permissions()
+
+    def has_all_permissions(self, *permissions: str) -> bool:
+        """Check if user has ALL specified permissions.
+
+        Admins have all permissions. For other users, checks if all permissions
+        exist in their combined group permissions.
+        """
+        if self.is_admin:
+            return True
+        user_permissions = self.get_permissions()
+        return all(p in user_permissions for p in permissions)
+
+    def has_any_permission(self, *permissions: str) -> bool:
+        """Check if user has ANY of the specified permissions.
+
+        Admins have all permissions. For other users, checks if at least one
+        permission exists in their combined group permissions.
+        """
+        if self.is_admin:
+            return True
+        user_permissions = self.get_permissions()
+        return any(p in user_permissions for p in permissions)
+
+    def __repr__(self) -> str:
+        return f"<User {self.username}>"

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

@@ -1,6 +1,16 @@
 from pydantic import BaseModel
 
 
+class GroupBrief(BaseModel):
+    """Brief group info for embedding in user responses."""
+
+    id: int
+    name: str
+
+    class Config:
+        from_attributes = True
+
+
 class LoginRequest(BaseModel):
     username: str
     password: str
@@ -16,6 +26,7 @@ class UserCreate(BaseModel):
     username: str
     password: str
     role: str = "user"
+    group_ids: list[int] | None = None
 
 
 class UserUpdate(BaseModel):
@@ -23,19 +34,28 @@ class UserUpdate(BaseModel):
     password: str | None = None
     role: str | None = None
     is_active: bool | None = None
+    group_ids: list[int] | None = None
 
 
 class UserResponse(BaseModel):
     id: int
     username: str
-    role: str
+    role: str  # Deprecated, kept for backward compatibility
     is_active: bool
+    is_admin: bool  # Computed from role and group membership
+    groups: list[GroupBrief] = []
+    permissions: list[str] = []  # All permissions from groups
     created_at: str
 
     class Config:
         from_attributes = True
 
 
+class ChangePasswordRequest(BaseModel):
+    current_password: str
+    new_password: str
+
+
 class SetupRequest(BaseModel):
     auth_enabled: bool
     admin_username: str | None = None

+ 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,
         external_link,
         filament,
+        group,
         kprofile_note,
         maintenance,
         notification,
@@ -122,6 +123,11 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
         patch("backend.app.core.auth.async_session", test_async_session),
         patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
     ):
+        # Seed default groups for tests that need them
+        from backend.app.core.database import seed_default_groups
+
+        await seed_default_groups()
+
         async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
             yield client
 

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

@@ -205,7 +205,18 @@ class TestUsersAPI:
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_list_users_requires_auth(self, async_client: AsyncClient):
-        """Verify listing users requires authentication."""
+        """Verify listing users requires authentication when auth is enabled."""
+        # First enable auth
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "authreqadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        # Now try to list users without a token
         response = await async_client.get("/api/v1/users/")
 
         assert response.status_code == 401
@@ -360,3 +371,321 @@ class TestAuthDisableAPI:
         # Verify auth is now disabled
         status_response = await async_client.get("/api/v1/auth/status")
         assert status_response.json()["auth_enabled"] is False
+
+
+class TestGroupsAPI:
+    """Integration tests for /api/v1/groups/ endpoints."""
+
+    @pytest.fixture
+    async def auth_token(self, async_client: AsyncClient):
+        """Setup auth and return admin token."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "groupsadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "groupsadmin", "password": "adminpassword123"},
+        )
+        return login_response.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_groups(self, async_client: AsyncClient, auth_token: str):
+        """Verify listing groups returns default groups."""
+        response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 200
+        groups = response.json()
+        assert isinstance(groups, list)
+        # Should have default groups: Administrators, Operators, Viewers
+        group_names = [g["name"] for g in groups]
+        assert "Administrators" in group_names
+        assert "Operators" in group_names
+        assert "Viewers" in group_names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_permissions(self, async_client: AsyncClient, auth_token: str):
+        """Verify getting available permissions."""
+        response = await async_client.get(
+            "/api/v1/groups/permissions",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 200
+        permissions = response.json()
+        assert isinstance(permissions, dict)
+        # Should have permission categories
+        assert "Printers" in permissions or len(permissions) > 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify creating a new group."""
+        response = await async_client.post(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "name": "Custom Group",
+                "description": "A custom test group",
+                "permissions": ["printers:read", "archives:read"],
+            },
+        )
+
+        assert response.status_code == 201
+        group = response.json()
+        assert group["name"] == "Custom Group"
+        assert group["description"] == "A custom test group"
+        assert "printers:read" in group["permissions"]
+        assert group["is_system"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify updating a group."""
+        # Create a group first
+        create_response = await async_client.post(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "name": "Update Test Group",
+                "permissions": ["printers:read"],
+            },
+        )
+        group_id = create_response.json()["id"]
+
+        # Update the group
+        response = await async_client.patch(
+            f"/api/v1/groups/{group_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "description": "Updated description",
+                "permissions": ["printers:read", "printers:control"],
+            },
+        )
+
+        assert response.status_code == 200
+        group = response.json()
+        assert group["description"] == "Updated description"
+        assert "printers:control" in group["permissions"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cannot_delete_system_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify system groups cannot be deleted."""
+        # Get the Administrators group
+        list_response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        admin_group = next(g for g in list_response.json() if g["name"] == "Administrators")
+
+        # Try to delete it
+        response = await async_client.delete(
+            f"/api/v1/groups/{admin_group['id']}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 400
+        assert "system group" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_custom_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify custom groups can be deleted."""
+        # Create a group
+        create_response = await async_client.post(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={"name": "Delete Test Group"},
+        )
+        group_id = create_response.json()["id"]
+
+        # Delete it
+        response = await async_client.delete(
+            f"/api/v1/groups/{group_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 204
+
+
+class TestUserGroupsAPI:
+    """Integration tests for user-group assignments."""
+
+    @pytest.fixture
+    async def auth_token(self, async_client: AsyncClient):
+        """Setup auth and return admin token."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "usergroupadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "usergroupadmin", "password": "adminpassword123"},
+        )
+        return login_response.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_user_with_groups(self, async_client: AsyncClient, auth_token: str):
+        """Verify creating a user with group assignments."""
+        # Get Operators group ID
+        groups_response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        operators_group = next(g for g in groups_response.json() if g["name"] == "Operators")
+
+        # Create user with group
+        response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={
+                "username": "groupuser",
+                "password": "password123",
+                "group_ids": [operators_group["id"]],
+            },
+        )
+
+        assert response.status_code == 201
+        user = response.json()
+        assert any(g["name"] == "Operators" for g in user["groups"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_user_to_group(self, async_client: AsyncClient, auth_token: str):
+        """Verify adding a user to a group."""
+        # Create a user
+        user_response = await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+            json={"username": "addtogroup", "password": "password123"},
+        )
+        user_id = user_response.json()["id"]
+
+        # Get Viewers group
+        groups_response = await async_client.get(
+            "/api/v1/groups/",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        viewers_group = next(g for g in groups_response.json() if g["name"] == "Viewers")
+
+        # Add user to group
+        response = await async_client.post(
+            f"/api/v1/groups/{viewers_group['id']}/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+
+        assert response.status_code == 204
+
+        # Verify user is in group
+        user_check = await async_client.get(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        assert any(g["name"] == "Viewers" for g in user_check.json()["groups"])
+
+
+class TestChangePasswordAPI:
+    """Integration tests for /api/v1/users/me/change-password endpoint."""
+
+    @pytest.fixture
+    async def user_token(self, async_client: AsyncClient):
+        """Setup auth and return regular user token."""
+        # Enable auth with admin
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "pwchangeadmin",
+                "admin_password": "adminpassword123",
+            },
+        )
+
+        admin_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "pwchangeadmin", "password": "adminpassword123"},
+        )
+        admin_token = admin_login.json()["access_token"]
+
+        # Create a regular user
+        await async_client.post(
+            "/api/v1/users/",
+            headers={"Authorization": f"Bearer {admin_token}"},
+            json={"username": "pwchangeuser", "password": "oldpassword123"},
+        )
+
+        # Login as regular user
+        user_login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "pwchangeuser", "password": "oldpassword123"},
+        )
+        return user_login.json()["access_token"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_change_password_success(self, async_client: AsyncClient, user_token: str):
+        """Verify user can change their own password."""
+        response = await async_client.post(
+            "/api/v1/users/me/change-password",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={
+                "current_password": "oldpassword123",
+                "new_password": "newpassword456",
+            },
+        )
+
+        assert response.status_code == 200
+        assert "success" in response.json()["message"].lower()
+
+        # Verify can login with new password
+        login_response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "pwchangeuser", "password": "newpassword456"},
+        )
+        assert login_response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_change_password_wrong_current(self, async_client: AsyncClient, user_token: str):
+        """Verify changing password fails with wrong current password."""
+        response = await async_client.post(
+            "/api/v1/users/me/change-password",
+            headers={"Authorization": f"Bearer {user_token}"},
+            json={
+                "current_password": "wrongpassword",
+                "new_password": "newpassword456",
+            },
+        )
+
+        assert response.status_code == 400
+        assert "incorrect" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_change_password_requires_auth(self, async_client: AsyncClient):
+        """Verify changing password requires authentication."""
+        response = await async_client.post(
+            "/api/v1/users/me/change-password",
+            json={
+                "current_password": "oldpassword",
+                "new_password": "newpassword",
+            },
+        )
+
+        assert response.status_code == 401

+ 4 - 4
frontend/src/App.tsx

@@ -16,7 +16,6 @@ import { ExternalLinkPage } from './pages/ExternalLinkPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
 import { SetupPage } from './pages/SetupPage';
-import { UsersPage } from './pages/UsersPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -51,7 +50,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
 }
 
 function AdminRoute({ children }: { children: React.ReactNode }) {
-  const { authEnabled, loading, user } = useAuth();
+  const { authEnabled, loading, user, isAdmin } = useAuth();
 
   if (loading) {
     return <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.role !== 'admin') {
+  if (!isAdmin) {
     return <Navigate to="/" replace />;
   }
 
@@ -121,7 +120,8 @@ function App() {
                   <Route path="projects/:id" element={<ProjectDetailPage />} />
                   <Route path="files" element={<FileManagerPage />} />
                   <Route path="settings" element={<AdminRoute><SettingsPage /></AdminRoute>} />
-                  <Route path="users" element={<UsersPage />} />
+                  <Route path="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="external/:id" element={<ExternalLinkPage />} />
                 </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
   // ========================================================================

+ 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 () => {
       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(
         http.post('/api/v1/auth/login', async () => {
-          await new Promise(resolve => setTimeout(resolve, 100));
+          await loginPromise;
           return HttpResponse.json({
             access_token: 'test-token',
             token_type: 'bearer',
@@ -148,10 +150,13 @@ describe('LoginPage', () => {
       await user.type(screen.getByLabelText(/Password/i), 'testpass');
       await user.click(screen.getByRole('button', { name: /Sign in/i }));
 
-      // Check for loading state
+      // Check for loading state - button text should change to "Logging in..."
       await waitFor(() => {
-        expect(screen.getByRole('button')).toBeDisabled();
+        expect(screen.getByRole('button', { name: /Logging in/i })).toBeInTheDocument();
       });
+
+      // Release the login request
+      resolveLogin!();
     });
   });
 });

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

@@ -50,6 +50,12 @@ async function request<T>(
     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();
 }
 
@@ -1658,6 +1664,82 @@ export interface ExternalLinkUpdate {
   icon?: string;
 }
 
+// Permission type - all available permissions
+export type Permission =
+  | 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files'
+  | 'archives:read' | 'archives:create' | 'archives:update' | 'archives:delete' | 'archives:reprint'
+  | 'queue:read' | 'queue:create' | 'queue:update' | 'queue:delete' | 'queue:reorder'
+  | 'library:read' | 'library:upload' | 'library:update' | 'library:delete'
+  | 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
+  | 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
+  | 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
+  | 'camera:view'
+  | 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
+  | 'kprofiles:read' | 'kprofiles:create' | 'kprofiles:update' | 'kprofiles:delete'
+  | 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete'
+  | 'notification_templates:read' | 'notification_templates:update'
+  | 'external_links:read' | 'external_links:create' | 'external_links:update' | 'external_links:delete'
+  | 'discovery:scan'
+  | 'firmware:read' | 'firmware:update'
+  | 'ams_history:read'
+  | 'stats:read'
+  | 'system:read'
+  | 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
+  | 'github:backup' | 'github:restore'
+  | 'cloud:auth'
+  | 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
+  | 'users:read' | 'users:create' | 'users:update' | 'users:delete'
+  | 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
+  | 'websocket:connect';
+
+// Group types
+export interface GroupBrief {
+  id: number;
+  name: string;
+}
+
+export interface Group {
+  id: number;
+  name: string;
+  description: string | null;
+  permissions: Permission[];
+  is_system: boolean;
+  user_count: number;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface GroupDetail extends Group {
+  users: Array<{ id: number; username: string; is_active: boolean }>;
+}
+
+export interface GroupCreate {
+  name: string;
+  description?: string;
+  permissions: Permission[];
+}
+
+export interface GroupUpdate {
+  name?: string;
+  description?: string;
+  permissions?: Permission[];
+}
+
+export interface PermissionInfo {
+  value: Permission;
+  label: string;
+}
+
+export interface PermissionCategory {
+  name: string;
+  permissions: PermissionInfo[];
+}
+
+export interface PermissionsListResponse {
+  categories: PermissionCategory[];
+  all_permissions: Permission[];
+}
+
 // Auth types
 export interface LoginRequest {
   username: string;
@@ -1673,8 +1755,11 @@ export interface LoginResponse {
 export interface UserResponse {
   id: number;
   username: string;
-  role: string;
+  role: string;  // Deprecated, kept for backward compatibility
   is_active: boolean;
+  is_admin: boolean;  // Computed from role and group membership
+  groups: GroupBrief[];
+  permissions: Permission[];  // All permissions from groups
   created_at: string;
 }
 
@@ -1682,6 +1767,7 @@ export interface UserCreate {
   username: string;
   password: string;
   role: string;
+  group_ids?: number[];
 }
 
 export interface UserUpdate {
@@ -1689,6 +1775,7 @@ export interface UserUpdate {
   password?: string;
   role?: string;
   is_active?: boolean;
+  group_ids?: number[];
 }
 
 export interface SetupRequest {
@@ -1731,7 +1818,7 @@ export const api = {
       method: 'POST',
     }),
 
-  // Users (admin only)
+  // Users
   getUsers: () => request<UserResponse[]>('/users/'),
   getUser: (id: number) => request<UserResponse>(`/users/${id}`),
   createUser: (data: UserCreate) =>
@@ -1748,6 +1835,38 @@ export const api = {
     request<void>(`/users/${id}`, {
       method: 'DELETE',
     }),
+  changePassword: (currentPassword: string, newPassword: string) =>
+    request<{ message: string }>('/users/me/change-password', {
+      method: 'POST',
+      body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
+    }),
+
+  // Groups
+  getPermissions: () => request<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
   getPrinters: () => request<Printer[]>('/printers/'),

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

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

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

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

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

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -10,6 +10,9 @@ import { api, supportApi, pendingUploadsApi } from '../api/client';
 import { getIconByName } from './IconPicker';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { Card, CardHeader, CardContent } from './Card';
+import { Button } from './Button';
 
 interface NavItem {
   id: string;
@@ -69,7 +72,11 @@ export function Layout() {
   const { mode, toggleMode } = useTheme();
   const { t } = useTranslation();
   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 stored = localStorage.getItem('sidebarExpanded');
     return stored !== 'false';
@@ -564,17 +571,26 @@ export function Layout() {
                     )}
                   </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
                   href="https://github.com/maziggy/bambuddy"
                   target="_blank"
@@ -599,13 +615,22 @@ export function Layout() {
                   {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
                 </button>
                 {authEnabled && user && (
-                  <button
-                    onClick={logout}
-                    className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                    title={t('nav.logout', { defaultValue: 'Logout' })}
-                  >
-                    <LogOut className="w-5 h-5" />
-                  </button>
+                  <>
+                    <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>
               {/* Bottom row: version */}
@@ -650,17 +675,26 @@ export function Layout() {
                   )}
                 </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
                 href="https://github.com/maziggy/bambuddy"
                 target="_blank"
@@ -685,13 +719,22 @@ export function Layout() {
                 {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
               </button>
               {authEnabled && user && (
-                <button
-                  onClick={logout}
-                  className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                  title={t('nav.logout', { defaultValue: 'Logout' })}
-                >
-                  <LogOut className="w-5 h-5" />
-                </button>
+                <>
+                  <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>
           )}
@@ -800,6 +843,141 @@ export function Layout() {
           </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>
   );
 }

+ 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 type { UserResponse } from '../api/client';
+import type { Permission, UserResponse } from '../api/client';
 
 interface AuthContextType {
   user: UserResponse | null;
   authEnabled: boolean;
   requiresSetup: boolean;
   loading: boolean;
+  isAdmin: boolean;
   login: (username: string, password: string) => Promise<void>;
   logout: () => void;
   refreshUser: () => Promise<void>;
   refreshAuth: () => Promise<void>;
+  hasPermission: (permission: Permission) => boolean;
+  hasAnyPermission: (...permissions: Permission[]) => boolean;
+  hasAllPermissions: (...permissions: Permission[]) => boolean;
 }
 
 const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -122,6 +126,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     await checkAuthStatus();
   };
 
+  // Memoize permission set for efficient lookups
+  const permissionSet = useMemo(() => {
+    return new Set(user?.permissions ?? []);
+  }, [user?.permissions]);
+
+  // Computed admin status
+  const isAdmin = useMemo(() => {
+    if (!authEnabled) return true; // Auth disabled = admin access
+    return user?.is_admin ?? false;
+  }, [authEnabled, user?.is_admin]);
+
+  // Permission check functions
+  const hasPermission = useCallback((permission: Permission): boolean => {
+    if (!authEnabled) return true; // Auth disabled = allow all
+    if (isAdmin) return true; // Admins have all permissions
+    return permissionSet.has(permission);
+  }, [authEnabled, isAdmin, permissionSet]);
+
+  const hasAnyPermission = useCallback((...permissions: Permission[]): boolean => {
+    if (!authEnabled) return true;
+    if (isAdmin) return true;
+    return permissions.some(p => permissionSet.has(p));
+  }, [authEnabled, isAdmin, permissionSet]);
+
+  const hasAllPermissions = useCallback((...permissions: Permission[]): boolean => {
+    if (!authEnabled) return true;
+    if (isAdmin) return true;
+    return permissions.every(p => permissionSet.has(p));
+  }, [authEnabled, isAdmin, permissionSet]);
+
   return (
     <AuthContext.Provider
       value={{
@@ -129,10 +163,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
         authEnabled,
         requiresSetup,
         loading,
+        isAdmin,
         login,
         logout,
         refreshUser,
         refreshAuth,
+        hasPermission,
+        hasAnyPermission,
+        hasAllPermissions,
       }}
     >
       {children}

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

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

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

@@ -45,12 +45,14 @@ import type {
   LibraryFolderUpdate,
   AppSettings,
   Archive,
+  Permission,
 } from '../api/client';
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
+import { useAuth } from '../contexts/AuthContext';
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
@@ -724,9 +726,10 @@ interface FolderTreeItemProps {
   onRename: (folder: LibraryFolderTree) => void;
   depth?: number;
   wrapNames?: boolean;
+  hasPermission: (permission: Permission) => boolean;
 }
 
-function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false }: FolderTreeItemProps) {
+function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, hasPermission }: FolderTreeItemProps) {
   const [expanded, setExpanded] = useState(true);
   const [showActions, setShowActions] = useState(false);
   const hasChildren = folder.children.length > 0;
@@ -799,22 +802,34 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
                 <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]">
                 <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" />
                   Rename
                 </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" />
                   {isLinked ? 'Change Link...' : 'Link to...'}
                 </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" />
                   Delete
@@ -838,6 +853,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
               onRename={onRename}
               depth={depth + 1}
               wrapNames={wrapNames}
+              hasPermission={hasPermission}
             />
           ))}
         </div>
@@ -865,9 +881,10 @@ interface FileCardProps {
   onRename?: (file: LibraryFileListItem) => void;
   onGenerateThumbnail?: (file: LibraryFileListItem) => void;
   thumbnailVersion?: number;
+  hasPermission: (permission: Permission) => boolean;
 }
 
-function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) {
+function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission }: FileCardProps) {
   const [showActions, setShowActions] = useState(false);
 
   return (
@@ -936,8 +953,12 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
             <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) && (
                 <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" />
                   Print
@@ -945,24 +966,36 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               )}
               {onAddToQueue && isSlicedFilename(file.filename) && (
                 <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" />
                   Add to Queue
                 </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
               </button>
               {onRename && (
                 <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" />
                   Rename
@@ -970,16 +1003,24 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
               )}
               {onGenerateThumbnail && file.file_type === 'stl' && (
                 <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" />
                   Generate Thumbnail
                 </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" />
                 Delete
@@ -1004,6 +1045,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
 export function FileManagerPage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [searchParams] = useSearchParams();
 
   // Read folder ID from URL query parameter
@@ -1470,8 +1512,8 @@ export function FileManagerPage() {
           <Button
             variant="secondary"
             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 ? (
               <Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -1480,11 +1522,20 @@ export function FileManagerPage() {
             )}
             Generate Thumbnails
           </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" />
             New Folder
           </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
           </Button>
@@ -1634,6 +1685,7 @@ export function FileManagerPage() {
                 onLink={setLinkFolder}
                 onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
                 wrapNames={wrapFolderNames}
+                hasPermission={hasPermission}
               />
             ))}
           </div>
@@ -1752,6 +1804,8 @@ export function FileManagerPage() {
                         variant="primary"
                         size="sm"
                         onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
+                        disabled={!hasPermission('printers:control')}
+                        title={!hasPermission('printers:control') ? 'You do not have permission to print' : undefined}
                       >
                         <Play className="w-4 h-4 sm:mr-1" />
                         <span className="hidden sm:inline">Print</span>
@@ -1762,7 +1816,8 @@ export function FileManagerPage() {
                         variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
                         size="sm"
                         onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
-                        disabled={addToQueueMutation.isPending}
+                        disabled={addToQueueMutation.isPending || !hasPermission('queue:create')}
+                        title={!hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined}
                       >
                         <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>
@@ -1772,6 +1827,8 @@ export function FileManagerPage() {
                       variant="secondary"
                       size="sm"
                       onClick={() => setShowMoveModal(true)}
+                      disabled={!hasPermission('library:update')}
+                      title={!hasPermission('library:update') ? 'You do not have permission to move files' : undefined}
                     >
                       <MoveRight className="w-4 h-4 sm:mr-1" />
                       <span className="hidden sm:inline">Move</span>
@@ -1786,6 +1843,8 @@ export function FileManagerPage() {
                           setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
                         }
                       }}
+                      disabled={!hasPermission('library:delete')}
+                      title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined}
                     >
                       <Trash2 className="w-4 h-4 sm:mr-1" />
                       <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 to start organizing your print-related files.'}
               </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" />
                 Upload Files
               </Button>
@@ -1860,6 +1923,7 @@ export function FileManagerPage() {
                     onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
                     onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
                     thumbnailVersion={thumbnailVersions[file.id]}
+                    hasPermission={hasPermission}
                   />
                 ))}
               </div>
@@ -1946,50 +2010,78 @@ export function FileManagerPage() {
                       {isSlicedFilename(file.filename) && (
                         <>
                           <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" />
                           </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" />
                           </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" />
                       </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" />
                       </button>
                       {file.file_type === 'stl' && (
                         <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" />
                         </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" />
                       </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 { useToast } from '../contexts/ToastContext';
 import { useTheme } from '../contexts/ThemeContext';
+import { HelpCircle, X } from 'lucide-react';
 
 export function LoginPage() {
   const navigate = useNavigate();
@@ -12,6 +13,7 @@ export function LoginPage() {
   const { mode } = useTheme();
   const [username, setUsername] = useState('');
   const [password, setPassword] = useState('');
+  const [showForgotPassword, setShowForgotPassword] = useState(false);
 
   const loginMutation = useMutation({
     mutationFn: () => login(username, password),
@@ -96,8 +98,67 @@ export function LoginPage() {
               {loginMutation.isPending ? 'Logging in...' : 'Sign in'}
             </button>
           </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>
       </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>
   );
 }

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

@@ -36,11 +36,12 @@ import {
   ExternalLink,
 } from 'lucide-react';
 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 { Button } from '../components/Button';
 import { Toggle } from '../components/Toggle';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 // Icon mapping for maintenance types
 const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -199,10 +200,12 @@ function MaintenanceCard({
   item,
   onPerform,
   onToggle,
+  hasPermission,
 }: {
   item: MaintenanceStatus;
   onPerform: (id: number) => void;
   onToggle: (id: number, enabled: boolean) => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
   const Icon = getIcon(item.maintenance_type_icon);
   const intervalType = item.interval_type || 'hours';
@@ -324,15 +327,19 @@ function MaintenanceCard({
 
         {/* Actions */}
         <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
             size="sm"
             variant={item.is_due ? 'primary' : 'secondary'}
             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"
           >
             <RotateCcw className="w-3.5 h-3.5" />
@@ -350,11 +357,13 @@ function PrinterSection({
   onPerform,
   onToggle,
   onSetHours,
+  hasPermission,
 }: {
   overview: PrinterMaintenanceOverview;
   onPerform: (id: number) => void;
   onToggle: (id: number, enabled: boolean) => void;
   onSetHours: (printerId: number, hours: number) => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
   const [expanded, setExpanded] = useState(true);
   const [editingHours, setEditingHours] = useState(false);
@@ -445,14 +454,16 @@ function PrinterSection({
             ) : (
               <button
                 onClick={() => {
+                  if (!hasPermission('maintenance:update')) return;
                   setHoursInput(Math.round(overview.total_print_hours).toString());
                   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
-                  <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 className="text-xs text-bambu-gray">Total print time</div>
               </button>
@@ -496,6 +507,7 @@ function PrinterSection({
                 item={item}
                 onPerform={onPerform}
                 onToggle={onToggle}
+                hasPermission={hasPermission}
               />
             ))}
           </div>
@@ -515,6 +527,7 @@ function SettingsSection({
   onDeleteType,
   onAssignType,
   onRemoveItem,
+  hasPermission,
 }: {
   overview: PrinterMaintenanceOverview[] | undefined;
   types: MaintenanceType[];
@@ -524,6 +537,7 @@ function SettingsSection({
   onDeleteType: (id: number) => void;
   onAssignType: (printerId: number, typeId: number) => void;
   onRemoveItem: (itemId: number) => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
   const [editingInterval, setEditingInterval] = useState<number | null>(null);
   const [intervalInput, setIntervalInput] = useState('');
@@ -654,7 +668,11 @@ function SettingsSection({
             <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>
           </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" />
             Add Custom Type
           </Button>
@@ -914,7 +932,9 @@ function SettingsSection({
                   </button>
                   <button
                     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" />
                   </button>
@@ -924,7 +944,9 @@ function SettingsSection({
                         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" />
                   </button>
@@ -946,8 +968,9 @@ function SettingsSection({
                             {p.printerName}
                             <button
                               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>
@@ -962,7 +985,9 @@ function SettingsSection({
                           <button
                             key={p.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}
                           </button>
@@ -1034,11 +1059,14 @@ function SettingsSection({
                           ) : (
                             <button
                               onClick={() => {
+                                if (!hasPermission('maintenance:update')) return;
                                 setEditingInterval(item.id);
                                 setIntervalInput(item.interval_hours.toString());
                                 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" />}
                               {formatIntervalLabel(item.interval_hours, intervalType)}
@@ -1076,6 +1104,7 @@ type TabType = 'status' | 'settings';
 export function MaintenancePage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [activeTab, setActiveTab] = useState<TabType>('status');
 
   const { data: overview, isLoading } = useQuery({
@@ -1259,6 +1288,7 @@ export function MaintenancePage() {
                 onPerform={handlePerform}
                 onToggle={handleToggle}
                 onSetHours={handleSetHours}
+                hasPermission={hasPermission}
               />
             ))
           ) : (
@@ -1293,6 +1323,7 @@ export function MaintenancePage() {
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
           onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
           onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
+          hasPermission={hasPermission}
         />
       )}
     </div>

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

@@ -1,6 +1,7 @@
 import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTheme } from '../contexts/ThemeContext';
+import { useAuth } from '../contexts/AuthContext';
 import {
   Plus,
   Link,
@@ -938,6 +939,7 @@ function PrinterCard({
   const queryClient = useQueryClient();
   const navigate = useNavigate();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [deleteArchives, setDeleteArchives] = useState(true);
@@ -1127,6 +1129,7 @@ function PrinterCard({
       queryClient.invalidateQueries({ queryKey: ['archives'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
     },
+    onError: (error: Error) => showToast(error.message || 'Failed to delete printer', 'error'),
   });
 
   const connectMutation = useMutation({
@@ -1560,11 +1563,17 @@ function PrinterCard({
               {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">
                   <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={() => {
+                      if (!hasPermission('printers:update')) return;
                       setShowEditModal(true);
                       setShowMenu(false);
                     }}
+                    title={!hasPermission('printers:update') ? 'You do not have permission to edit printers' : undefined}
                   >
                     <Pencil className="w-4 h-4" />
                     Edit
@@ -1590,11 +1599,17 @@ function PrinterCard({
                     MQTT Debug
                   </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={() => {
+                      if (!hasPermission('printers:delete')) return;
                       setShowDeleteConfirm(true);
                       setShowMenu(false);
                     }}
+                    title={!hasPermission('printers:delete') ? 'You do not have permission to delete printers' : undefined}
                   >
                     <Trash2 className="w-4 h-4" />
                     Delete
@@ -1801,18 +1816,20 @@ function PrinterCard({
                   {/* Skip Objects button - top right corner, always visible */}
                   <button
                     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 ${
-                      (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/30 cursor-not-allowed'
                     }`}
                     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" />
@@ -2027,16 +2044,16 @@ function PrinterCard({
                       {/* Stop button */}
                       <button
                         onClick={() => setShowStopConfirm(true)}
-                        disabled={!isPrinting || isControlBusy}
+                        disabled={!isPrinting || isControlBusy || !hasPermission('printers:control')}
                         className={`
                           flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
                           transition-colors
-                          ${isPrinting
+                          ${isPrinting && hasPermission('printers:control')
                             ? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
                             : '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" />
                         Stop
@@ -2045,18 +2062,18 @@ function PrinterCard({
                       {/* Pause/Resume button */}
                       <button
                         onClick={() => isPaused ? setShowResumeConfirm(true) : setShowPauseConfirm(true)}
-                        disabled={!isPrinting || isControlBusy}
+                        disabled={!isPrinting || isControlBusy || !hasPermission('printers:control')}
                         className={`
                           flex items-center justify-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium
                           transition-colors
-                          ${isPrinting
+                          ${isPrinting && hasPermission('printers:control')
                             ? isPaused
                               ? '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-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 ? 'Resume' : 'Pause'}
@@ -2234,13 +2251,19 @@ function PrinterCard({
                                     {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]">
                                         <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) => {
                                             e.stopPropagation();
+                                            if (!hasPermission('printers:control')) return;
                                             refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: slotIdx });
                                             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' : ''}`} />
                                           Re-read RFID
@@ -2266,7 +2289,7 @@ function PrinterCard({
                                           } : undefined,
                                         }}
                                         configureSlot={{
-                                          enabled: true,
+                                          enabled: hasPermission('printers:control'),
                                           onConfigure: () => setConfigureSlotModal({
                                             amsId: ams.id,
                                             trayId: slotIdx,
@@ -2283,7 +2306,7 @@ function PrinterCard({
                                     ) : (
                                       <EmptySlotHoverCard
                                         configureSlot={{
-                                          enabled: true,
+                                          enabled: hasPermission('printers:control'),
                                           onConfigure: () => setConfigureSlotModal({
                                             amsId: ams.id,
                                             trayId: slotIdx,
@@ -2417,13 +2440,19 @@ function PrinterCard({
                                 {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]">
                                     <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) => {
                                         e.stopPropagation();
+                                        if (!hasPermission('printers:control')) return;
                                         refreshAmsSlotMutation.mutate({ amsId: ams.id, slotId: htSlotId });
                                         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' : ''}`} />
                                       Re-read RFID
@@ -2449,7 +2478,7 @@ function PrinterCard({
                                       } : undefined,
                                     }}
                                     configureSlot={{
-                                      enabled: true,
+                                      enabled: hasPermission('printers:control'),
                                       onConfigure: () => setConfigureSlotModal({
                                         amsId: ams.id,
                                         trayId: htSlotId,
@@ -2466,7 +2495,7 @@ function PrinterCard({
                                 ) : (
                                   <EmptySlotHoverCard
                                     configureSlot={{
-                                      enabled: true,
+                                      enabled: hasPermission('printers:control'),
                                       onConfigure: () => setConfigureSlotModal({
                                         amsId: ams.id,
                                         trayId: htSlotId,
@@ -2577,7 +2606,7 @@ function PrinterCard({
                                 } : undefined,
                               }}
                               configureSlot={{
-                                enabled: true,
+                                enabled: hasPermission('printers:control'),
                                 onConfigure: () => setConfigureSlotModal({
                                   amsId: 255, // External spool indicator
                                   trayId: 0,
@@ -2636,24 +2665,30 @@ function PrinterCard({
               <div className="flex items-center gap-1">
                 <button
                   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 ${
-                    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" />
                   On
                 </button>
                 <button
                   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 ${
-                    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" />
                   Off
@@ -2667,12 +2702,14 @@ function PrinterCard({
                 </span>
                 <button
                   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 ${
-                    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
@@ -2699,8 +2736,8 @@ function PrinterCard({
                 variant="secondary"
                 size="sm"
                 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' : ''}
               >
                 <ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
@@ -2737,8 +2774,8 @@ function PrinterCard({
                   variant="secondary"
                   size="sm"
                   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" : ""}`}
                 >
                   {plateDetectionMutation.isPending ? (
@@ -2751,8 +2788,8 @@ function PrinterCard({
                   variant="secondary"
                   size="sm"
                   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" : ""}`}
                 >
                   {isCheckingPlate ? (
@@ -2766,7 +2803,8 @@ function PrinterCard({
                 variant="secondary"
                 size="sm"
                 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" />
                 Files
@@ -3332,13 +3370,13 @@ function PrinterCard({
                       {!obj.skipped ? (
                         <button
                           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 ${
-                            (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-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
                         </button>
@@ -4026,6 +4064,7 @@ function EditPrinterModal({
   onClose: () => void;
 }) {
   const queryClient = useQueryClient();
+  const { showToast } = useToast();
   const [form, setForm] = useState({
     name: printer.name,
     ip_address: printer.ip_address,
@@ -4042,6 +4081,7 @@ function EditPrinterModal({
       queryClient.invalidateQueries({ queryKey: ['printerStatus', printer.id] });
       onClose();
     },
+    onError: (error: Error) => showToast(error.message || 'Failed to update printer', 'error'),
   });
 
   // Close on Escape key
@@ -4277,6 +4317,8 @@ export function PrintersPage() {
   // Derive viewMode from cardSize: S=compact, M/L/XL=expanded
   const viewMode: ViewMode = cardSize === 1 ? 'compact' : 'expanded';
   const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   // Embedded camera viewer state - supports multiple simultaneous viewers
   // Persisted to localStorage so cameras reopen after navigation
   const [embeddedCameraPrinters, setEmbeddedCameraPrinters] = useState<Map<number, { id: number; name: string }>>(() => {
@@ -4382,6 +4424,7 @@ export function PrintersPage() {
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
       setShowAddModal(false);
     },
+    onError: (error: Error) => showToast(error.message || 'Failed to add printer', 'error'),
   });
 
   const powerOnMutation = useMutation({
@@ -4602,7 +4645,11 @@ export function PrintersPage() {
               )}
             </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" />
             Add Printer
           </Button>
@@ -4615,7 +4662,11 @@ export function PrintersPage() {
         <Card>
           <CardContent className="text-center py-12">
             <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" />
               Add Your First Printer
             </Button>

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

@@ -42,10 +42,11 @@ import {
 } from 'lucide-react';
 import { api } from '../api/client';
 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 { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 import { KProfilesView } from '../components/KProfilesView';
 
 type ProfileTab = 'cloud' | 'kprofiles';
@@ -506,12 +507,14 @@ function PresetDetailModal({
   onDeleted,
   onDuplicate,
   onEdit,
+  hasPermission,
 }: {
   setting: SlicerSetting;
   onClose: () => void;
   onDeleted: () => void;
   onDuplicate: () => void;
   onEdit: () => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
   const { showToast } = useToast();
   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 gap-2">
                 <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" />
                   Duplicate
                 </Button>
                 {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" />
                       Edit
                     </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" />
                     </Button>
                   </>
@@ -2206,12 +2224,14 @@ function CloudProfilesView({
   onRefresh,
   isRefreshing,
   printers,
+  hasPermission,
 }: {
   settings: SlicerSettingsResponse;
   lastSyncTime?: Date;
   onRefresh: () => void;
   isRefreshing: boolean;
   printers: Printer[];
+  hasPermission: (permission: Permission) => boolean;
 }) {
   const [searchQuery, setSearchQuery] = useState('');
   const [filterType, setFilterType] = useState<PresetType>('all');
@@ -2424,15 +2444,29 @@ function CloudProfilesView({
               <GitCompare className="w-4 h-4" />
               {compareMode ? 'Cancel' : 'Compare'}
             </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" />
               Templates
             </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' : ''}`} />
               Refresh
             </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" />
               New Preset
             </Button>
@@ -2704,6 +2738,7 @@ function CloudProfilesView({
           onDeleted={() => setSelectedSetting(null)}
           onDuplicate={() => handleDuplicate(selectedSetting)}
           onEdit={() => handleEdit(selectedSetting)}
+          hasPermission={hasPermission}
         />
       )}
 
@@ -2748,6 +2783,7 @@ function CloudProfilesView({
 export function ProfilesPage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [activeTab, setActiveTab] = useState<ProfileTab>('cloud');
   const [lastSyncTime, setLastSyncTime] = useState<Date>();
 
@@ -2846,7 +2882,8 @@ export function ProfilesPage() {
                 variant="secondary"
                 size="sm"
                 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
@@ -2867,6 +2904,7 @@ export function ProfilesPage() {
               onRefresh={() => refetchSettings()}
               isRefreshing={settingsLoading}
               printers={printers}
+              hasPermission={hasPermission}
             />
           ) : (
             <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 { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 import { RichTextEditor } from '../components/RichTextEditor';
 import { ConfirmModal } from '../components/ConfirmModal';
 
@@ -198,6 +199,7 @@ export function ProjectDetailPage() {
   const navigate = useNavigate();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [showEditModal, setShowEditModal] = useState(false);
   const [editingNotes, setEditingNotes] = useState(false);
   const [notesContent, setNotesContent] = useState('');
@@ -494,11 +496,20 @@ export function ProjectDetailPage() {
           <StatusBadge status={project.status} />
         </div>
         <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" />
             Export
           </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" />
             Edit
           </Button>
@@ -761,7 +772,13 @@ export function ProjectDetailPage() {
               Notes
             </h2>
             {!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" />
                 Edit
               </Button>
@@ -886,7 +903,13 @@ export function ProjectDetailPage() {
                 </button>
               )}
               {!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" />
                   Add Part
                 </Button>
@@ -1031,12 +1054,15 @@ export function ProjectDetailPage() {
                     // Display mode
                     <div className="flex items-start gap-3">
                       <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 ${
                           item.is_complete
                             ? '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" />}
@@ -1058,16 +1084,26 @@ export function ProjectDetailPage() {
                           </div>
                           <div className="flex items-center gap-1">
                             <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" />
                             </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" />
                             </button>
@@ -1178,7 +1214,8 @@ export function ProjectDetailPage() {
             variant="secondary"
             size="sm"
             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 ? (
               <Loader2 className="w-4 h-4 animate-spin mr-2" />

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

@@ -20,10 +20,11 @@ import {
   Upload,
 } from 'lucide-react';
 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 { ConfirmModal } from '../components/ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 const PROJECT_COLORS = [
   '#ef4444', // red
@@ -244,9 +245,10 @@ interface ProjectCardProps {
   onClick: () => void;
   onEdit: () => 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
   const platesProgressPercent = project.target_count
     ? 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="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
-                    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" />
                     Edit
                   </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" />
                     Delete
@@ -565,6 +575,7 @@ export function ProjectsPage() {
   const navigate = useNavigate();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [showModal, setShowModal] = useState(false);
   const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
   const [statusFilter, setStatusFilter] = useState<string>('active');
@@ -763,15 +774,30 @@ export function ProjectsPage() {
           </p>
         </div>
         <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" />
             Import
           </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" />
             Export
           </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" />
             New Project
           </Button>
@@ -831,7 +857,11 @@ export function ProjectsPage() {
             }
           </p>
           {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" />
               Create Your First Project
             </Button>
@@ -846,6 +876,7 @@ export function ProjectsPage() {
               onClick={() => handleClick(project)}
               onEdit={() => handleEdit(project)}
               onDelete={() => handleDeleteClick(project.id)}
+              hasPermission={hasPermission}
             />
           ))}
         </div>

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

@@ -47,12 +47,13 @@ import {
 } from 'lucide-react';
 import { api } from '../api/client';
 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 { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { PrintModal } from '../components/PrintModal';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 
 function formatDuration(seconds: number | null | undefined): string {
   if (!seconds) return '--';
@@ -277,6 +278,7 @@ function SortableQueueItem({
   timeFormat = 'system',
   isSelected = false,
   onToggleSelect,
+  hasPermission,
 }: {
   item: PrintQueueItem;
   position?: number;
@@ -289,7 +291,9 @@ function SortableQueueItem({
   timeFormat?: TimeFormat;
   isSelected?: boolean;
   onToggleSelect?: () => void;
+  hasPermission: (permission: Permission) => boolean;
 }) {
+  const canReorder = hasPermission('queue:reorder');
   const {
     attributes,
     listeners,
@@ -297,7 +301,7 @@ function SortableQueueItem({
     transform,
     transition,
     isDragging,
-  } = useSortable({ id: item.id, disabled: item.status !== 'pending' });
+  } = useSortable({ id: item.id, disabled: item.status !== 'pending' || !canReorder });
 
   const style = {
     transform: CSS.Transform.toString(transform),
@@ -489,7 +493,8 @@ function SortableQueueItem({
               variant="ghost"
               size="sm"
               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"
             >
               <StopCircle className="w-4 h-4" />
@@ -502,7 +507,8 @@ function SortableQueueItem({
                   variant="ghost"
                   size="sm"
                   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"
                 >
                   <Play className="w-4 h-4" />
@@ -512,7 +518,8 @@ function SortableQueueItem({
                 variant="ghost"
                 size="sm"
                 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" />
               </Button>
@@ -520,7 +527,8 @@ function SortableQueueItem({
                 variant="ghost"
                 size="sm"
                 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"
               >
                 <X className="w-4 h-4" />
@@ -533,7 +541,8 @@ function SortableQueueItem({
                 variant="ghost"
                 size="sm"
                 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"
               >
                 <RefreshCw className="w-4 h-4" />
@@ -542,7 +551,8 @@ function SortableQueueItem({
                 variant="ghost"
                 size="sm"
                 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" />
               </Button>
@@ -557,6 +567,7 @@ function SortableQueueItem({
 export function QueuePage() {
   const queryClient = useQueryClient();
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
   const [filterStatus, setFilterStatus] = useState<string>('');
   const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
@@ -910,6 +921,8 @@ export function QueuePage() {
             variant="secondary"
             size="sm"
             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" />
             Clear History
@@ -949,6 +962,7 @@ export function QueuePage() {
                     onRequeue={() => {}}
                     onStart={() => {}}
                     timeFormat={timeFormat}
+                    hasPermission={hasPermission}
                   />
                 ))}
               </div>
@@ -1018,6 +1032,8 @@ export function QueuePage() {
                       size="sm"
                       onClick={() => setShowBulkEditModal(true)}
                       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" />
                       Edit Selected
@@ -1027,7 +1043,8 @@ export function QueuePage() {
                       size="sm"
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       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" />
                       Cancel Selected
@@ -1060,6 +1077,7 @@ export function QueuePage() {
                         timeFormat={timeFormat}
                         isSelected={selectedItems.includes(item.id)}
                         onToggleSelect={() => handleToggleSelect(item.id)}
+                        hasPermission={hasPermission}
                       />
                     ))}
                   </div>
@@ -1113,6 +1131,7 @@ export function QueuePage() {
                     onRequeue={() => setRequeueItem(item)}
                     onStart={() => {}}
                     timeFormat={timeFormat}
+                    hasPermission={hasPermission}
                   />
                 ))}
               </div>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1008 - 170
frontend/src/pages/SettingsPage.tsx


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

@@ -20,6 +20,7 @@ import {
 } from 'lucide-react';
 import { Button } from '../components/Button';
 import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
 import { api } from '../api/client';
 import { PrintCalendar } from '../components/PrintCalendar';
 import { FilamentTrends } from '../components/FilamentTrends';
@@ -505,6 +506,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
 
 export function StatsPage() {
   const { showToast } = useToast();
+  const { hasPermission } = useAuth();
   const [isExporting, setIsExporting] = useState(false);
   const [showExportMenu, setShowExportMenu] = useState(false);
   const [dashboardKey, setDashboardKey] = useState(0);
@@ -683,6 +685,8 @@ export function StatsPage() {
               setDashboardKey(prev => prev + 1);
               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" />
             Reset Layout
@@ -691,8 +695,8 @@ export function StatsPage() {
           <Button
             variant="secondary"
             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 ? (
               <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 { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
 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 { useToast } from '../contexts/ToastContext';
 import { Button } from '../components/Button';
 import { Card, CardContent, CardHeader } from '../components/Card';
 import { ConfirmModal } from '../components/ConfirmModal';
 
+interface FormData extends UserCreate {
+  group_ids: number[];
+  confirmPassword: string;
+}
+
 export function UsersPage() {
   const navigate = useNavigate();
-  const { user: currentUser } = useAuth();
+  const { user: currentUser, hasPermission } = useAuth();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   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 [formData, setFormData] = useState<UserCreate>({
+  const [formData, setFormData] = useState<FormData>({
     username: '',
     password: '',
+    confirmPassword: '',
     role: 'user',
+    group_ids: [],
   });
 
   // Close modal on Escape key
   useEffect(() => {
     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);
     return () => window.removeEventListener('keydown', handleKeyDown);
-  }, [showCreateModal]);
+  }, [showCreateModal, showEditModal]);
 
   const { data: users = [], isLoading } = useQuery({
     queryKey: ['users'],
     queryFn: () => api.getUsers(),
+    enabled: hasPermission('users:read'),
+  });
+
+  const { data: groups = [] } = useQuery({
+    queryKey: ['groups'],
+    queryFn: () => api.getGroups(),
+    enabled: hasPermission('groups:read'),
   });
 
   const createMutation = useMutation({
     mutationFn: (data: UserCreate) => api.createUser(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['users'] });
+      queryClient.invalidateQueries({ queryKey: ['groups'] });
       setShowCreateModal(false);
-      setFormData({ username: '', password: '', role: 'user' });
+      setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
       showToast('User created successfully');
     },
     onError: (error: Error) => {
@@ -58,8 +81,10 @@ export function UsersPage() {
     mutationFn: ({ id, data }: { id: number; data: UserUpdate }) => api.updateUser(id, data),
     onSuccess: () => {
       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');
     },
     onError: (error: Error) => {
@@ -83,14 +108,39 @@ export function UsersPage() {
       showToast('Please fill in all required fields', 'error');
       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) => {
+    // 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 = {
       username: formData.username || undefined,
       password: formData.password || undefined,
       role: formData.role,
+      group_ids: formData.group_ids,
     };
     // Remove password if empty
     if (!updateData.password) {
@@ -103,16 +153,34 @@ export function UsersPage() {
     setDeleteUserId(id);
   };
 
-  const startEdit = (user: { id: number; username: string; role: string }) => {
-    setEditingUser(user.id);
+  const startEdit = (user: UserResponse) => {
+    setEditingUserId(user.id);
     setFormData({
       username: user.username,
       password: '',
+      confirmPassword: '',
       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 (
       <div className="p-6">
         <Card>
@@ -151,7 +219,7 @@ export function UsersPage() {
         <Button
           onClick={() => {
             setShowCreateModal(true);
-            setFormData({ username: '', password: '', role: 'user' });
+            setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
           }}
         >
           <Plus className="w-4 h-4" />
@@ -173,7 +241,7 @@ export function UsersPage() {
                     Username
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    Role
+                    Groups
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
                     Status
@@ -187,36 +255,35 @@ export function UsersPage() {
                 {users.map((user) => (
                   <tr key={user.id} className="hover:bg-bambu-dark-tertiary/50 transition-colors">
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
-                      {editingUser === user.id ? (
-                        <input
-                          type="text"
-                          value={formData.username}
-                          onChange={(e) => setFormData({ ...formData, username: e.target.value })}
-                          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green"
-                        />
-                      ) : (
-                        user.username
-                      )}
+                      {user.username}
                     </td>
-                    <td className="px-6 py-4 whitespace-nowrap text-sm">
-                      {editingUser === user.id ? (
-                        <select
-                          value={formData.role}
-                          onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
-                          className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green"
-                        >
-                          <option value="user">User</option>
-                          <option value="admin">Admin</option>
-                        </select>
-                      ) : (
-                        <span className={`px-3 py-1 rounded-full text-xs font-medium ${
-                          user.role === 'admin'
-                            ? 'bg-purple-500/20 text-purple-300'
-                            : 'bg-blue-500/20 text-blue-300'
-                        }`}>
-                          {user.role}
-                        </span>
-                      )}
+                    <td 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 className="px-6 py-4 whitespace-nowrap text-sm">
                       <span className={`px-3 py-1 rounded-full text-xs font-medium ${
@@ -228,53 +295,26 @@ export function UsersPage() {
                       </span>
                     </td>
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
-                      {editingUser === user.id ? (
-                        <div className="flex items-center gap-2">
-                          <Button
-                            size="sm"
-                            onClick={() => handleUpdate(user.id)}
-                            disabled={updateMutation.isPending}
-                          >
-                            {updateMutation.isPending ? (
-                              <Loader2 className="w-4 h-4 animate-spin" />
-                            ) : (
-                              <Save className="w-4 h-4" />
-                            )}
-                            Save
-                          </Button>
-                          <Button
-                            size="sm"
-                            variant="secondary"
-                            onClick={() => {
-                              setEditingUser(null);
-                              setFormData({ username: '', password: '', role: 'user' });
-                            }}
-                          >
-                            Cancel
-                          </Button>
-                        </div>
-                      ) : (
-                        <div className="flex items-center gap-2">
+                      <div className="flex items-center gap-2">
+                        <Button
+                          size="sm"
+                          variant="ghost"
+                          onClick={() => startEdit(user)}
+                        >
+                          <Edit2 className="w-4 h-4" />
+                          Edit
+                        </Button>
+                        {user.id !== currentUser?.id && (
                           <Button
                             size="sm"
                             variant="ghost"
-                            onClick={() => startEdit(user)}
+                            onClick={() => handleDelete(user.id)}
                           >
-                            <Edit2 className="w-4 h-4" />
-                            Edit
+                            <Trash2 className="w-4 h-4" />
+                            Delete
                           </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>
                   </tr>
                 ))}
@@ -290,7 +330,7 @@ export function UsersPage() {
           className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
           onClick={() => {
             setShowCreateModal(false);
-            setFormData({ username: '', password: '', role: 'user' });
+            setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
           }}
         >
           <Card
@@ -308,7 +348,7 @@ export function UsersPage() {
                   size="sm"
                   onClick={() => {
                     setShowCreateModal(false);
-                    setFormData({ username: '', password: '', role: 'user' });
+                    setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
                   }}
                 >
                   <X className="w-5 h-5" />
@@ -346,16 +386,51 @@ export function UsersPage() {
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Role
+                    Confirm Password
                   </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 className="mt-6 flex justify-end gap-3">
@@ -363,14 +438,14 @@ export function UsersPage() {
                   variant="secondary"
                   onClick={() => {
                     setShowCreateModal(false);
-                    setFormData({ username: '', password: '', role: 'user' });
+                    setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
                   }}
                 >
                   Cancel
                 </Button>
                 <Button
                   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 ? (
                     <>
@@ -390,6 +465,140 @@ export function UsersPage() {
         </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 */}
       {deleteUserId !== null && (
         <ConfirmModal

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-B0_vH-u8.js


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-BrclLX7E.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-Bwf4poPr.css


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
static/assets/index-DlQJz8pN.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-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>
   <body>
     <div id="root"></div>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio