mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 07:25:44 +02:00
Merge pull request #201 from maziggy/feature/auth_details
Add group-based permissions system with granular access control
This commit is contained in:
@@ -5,6 +5,22 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.1.6-final] - Not released
|
||||
|
||||
### New Features
|
||||
- **Group-Based Permissions** - Granular access control with user groups:
|
||||
- Create custom groups with specific permissions (50+ granular permissions)
|
||||
- Default system groups: Administrators (full access), Operators (control printers), Viewers (read-only)
|
||||
- Users can belong to multiple groups with additive permissions
|
||||
- Permission-based UI: buttons/features disabled when user lacks permission
|
||||
- Groups management page in Settings → Users → Groups tab
|
||||
- Change password: users can change their own password from sidebar
|
||||
- Included in backup/restore
|
||||
- **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156):
|
||||
- Checkbox option when uploading STL files to generate thumbnails automatically
|
||||
- Batch generate thumbnails for existing STL files via "Generate Thumbnails" button
|
||||
- Individual file thumbnail generation via context menu (three-dot menu)
|
||||
- Works with ZIP extraction (generates thumbnails for all STL files in archive)
|
||||
- Uses trimesh and matplotlib for 3D rendering with Bambu green color theme
|
||||
- Thumbnails auto-refresh in UI after generation
|
||||
- Graceful handling of complex/invalid STL files
|
||||
- **Streaming Overlay for OBS** - Embeddable overlay page for live streaming with camera and print status (Issue #164):
|
||||
- All-in-one page at `/overlay/:printerId` combining camera feed with status overlay
|
||||
- Real-time print progress, ETA, layer count, and filename display
|
||||
|
||||
@@ -155,9 +155,10 @@
|
||||
|
||||
### 🔒 Optional Authentication
|
||||
- Enable/disable authentication any time
|
||||
- Role-based access (Admin/User)
|
||||
- Group-based permissions (50+ granular permissions)
|
||||
- Default groups: Administrators, Operators, Viewers
|
||||
- JWT tokens with secure password hashing
|
||||
- User management (create, edit, delete)
|
||||
- User management (create, edit, delete, groups)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
@@ -16,6 +16,7 @@ from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.external_link import ExternalLink
|
||||
from backend.app.models.filament import Filament
|
||||
from backend.app.models.github_backup import GitHubBackupConfig
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
|
||||
from backend.app.models.notification import NotificationProvider
|
||||
from backend.app.models.notification_template import NotificationTemplate
|
||||
@@ -259,6 +260,7 @@ async def export_backup(
|
||||
include_users: bool = Query(
|
||||
False, description="Include users (passwords not exported - users will need new passwords)"
|
||||
),
|
||||
include_groups: bool = Query(False, description="Include groups and user-group assignments"),
|
||||
include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"),
|
||||
):
|
||||
"""Export selected data as JSON backup."""
|
||||
@@ -876,11 +878,28 @@ async def export_backup(
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"groups": [g.name for g in user.groups],
|
||||
# password_hash intentionally not exported for security
|
||||
}
|
||||
)
|
||||
backup["included"].append("users")
|
||||
|
||||
# Groups (permission groups)
|
||||
if include_groups:
|
||||
result = await db.execute(select(Group))
|
||||
groups = result.scalars().all()
|
||||
backup["groups"] = []
|
||||
for group in groups:
|
||||
backup["groups"].append(
|
||||
{
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"permissions": group.permissions,
|
||||
"is_system": group.is_system,
|
||||
}
|
||||
)
|
||||
backup["included"].append("groups")
|
||||
|
||||
# GitHub backup configuration
|
||||
if include_github_backup:
|
||||
result = await db.execute(select(GitHubBackupConfig).limit(1))
|
||||
@@ -998,6 +1017,7 @@ async def import_backup(
|
||||
"projects": 0,
|
||||
"pending_uploads": 0,
|
||||
"users": 0,
|
||||
"groups": 0,
|
||||
"github_backup": 0,
|
||||
}
|
||||
skipped = {
|
||||
@@ -1013,6 +1033,7 @@ async def import_backup(
|
||||
"projects": 0,
|
||||
"pending_uploads": 0,
|
||||
"users": 0,
|
||||
"groups": 0,
|
||||
"github_backup": 0,
|
||||
}
|
||||
skipped_details = {
|
||||
@@ -1026,6 +1047,7 @@ async def import_backup(
|
||||
"projects": [],
|
||||
"pending_uploads": [],
|
||||
"users": [],
|
||||
"groups": [],
|
||||
}
|
||||
|
||||
# Restore settings (always overwrites)
|
||||
@@ -2036,6 +2058,39 @@ async def import_backup(
|
||||
}
|
||||
)
|
||||
|
||||
# Restore groups (before users, so groups exist for assignment)
|
||||
if "groups" in backup:
|
||||
for group_data in backup["groups"]:
|
||||
result = await db.execute(select(Group).where(Group.name == group_data["name"]))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
if overwrite and not existing.is_system:
|
||||
# Update non-system groups
|
||||
existing.description = group_data.get("description")
|
||||
existing.permissions = group_data.get("permissions", [])
|
||||
restored["groups"] += 1
|
||||
else:
|
||||
skipped["groups"] += 1
|
||||
skipped_details["groups"].append(group_data["name"])
|
||||
else:
|
||||
group = Group(
|
||||
name=group_data["name"],
|
||||
description=group_data.get("description"),
|
||||
permissions=group_data.get("permissions", []),
|
||||
is_system=group_data.get("is_system", False),
|
||||
)
|
||||
db.add(group)
|
||||
restored["groups"] += 1
|
||||
|
||||
# Flush to ensure groups are persisted before user assignment
|
||||
await db.flush()
|
||||
|
||||
# Build group name to object lookup for user assignment
|
||||
group_name_to_obj: dict[str, Group] = {}
|
||||
result = await db.execute(select(Group))
|
||||
for g in result.scalars().all():
|
||||
group_name_to_obj[g.name] = g
|
||||
|
||||
# Restore users (note: passwords not included in backup - users will need new passwords)
|
||||
# Users are skipped by default since they have no passwords; admin must recreate them
|
||||
new_users: list[str] = []
|
||||
@@ -2049,6 +2104,10 @@ async def import_backup(
|
||||
if overwrite:
|
||||
existing.role = user_data.get("role", "user")
|
||||
existing.is_active = user_data.get("is_active", True)
|
||||
# Assign groups if provided
|
||||
group_names = user_data.get("groups", [])
|
||||
if group_names:
|
||||
existing.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj]
|
||||
# Don't change password - keep existing
|
||||
restored["users"] += 1
|
||||
else:
|
||||
@@ -2066,6 +2125,10 @@ async def import_backup(
|
||||
role=user_data.get("role", "user"),
|
||||
is_active=user_data.get("is_active", True),
|
||||
)
|
||||
# Assign groups if provided
|
||||
group_names = user_data.get("groups", [])
|
||||
if group_names:
|
||||
user.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj]
|
||||
db.add(user)
|
||||
restored["users"] += 1
|
||||
new_users.append(f"{user_data['username']} (temp password: {temp_password})")
|
||||
|
||||
+138
-55
@@ -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
@@ -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))
|
||||
|
||||
@@ -39,6 +39,7 @@ async def init_db():
|
||||
external_link,
|
||||
filament,
|
||||
github_backup,
|
||||
group,
|
||||
kprofile_note,
|
||||
library,
|
||||
maintenance,
|
||||
@@ -62,6 +63,9 @@ async def init_db():
|
||||
# Seed default notification templates
|
||||
await seed_notification_templates()
|
||||
|
||||
# Seed default groups and migrate existing users
|
||||
await seed_default_groups()
|
||||
|
||||
|
||||
async def run_migrations(conn):
|
||||
"""Add new columns to existing tables if they don't exist."""
|
||||
@@ -894,6 +898,41 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Create groups table for permission-based access control
|
||||
try:
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description VARCHAR(500),
|
||||
permissions JSON,
|
||||
is_system BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
)
|
||||
await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Migration: Create user_groups association table
|
||||
try:
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def seed_notification_templates():
|
||||
"""Seed default notification templates if they don't exist."""
|
||||
@@ -931,3 +970,70 @@ async def seed_notification_templates():
|
||||
session.add(template)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def seed_default_groups():
|
||||
"""Seed default groups and migrate existing users to appropriate groups.
|
||||
|
||||
Creates the default system groups (Administrators, Operators, Viewers) if they
|
||||
don't exist, then migrates existing users:
|
||||
- Users with role='admin' -> Administrators group
|
||||
- Users with role='user' -> Operators group
|
||||
"""
|
||||
import logging
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.permissions import DEFAULT_GROUPS
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async with async_session() as session:
|
||||
# Get existing groups
|
||||
result = await session.execute(select(Group.name))
|
||||
existing_groups = {row[0] for row in result.fetchall()}
|
||||
|
||||
# Create default groups if they don't exist
|
||||
groups_created = []
|
||||
for group_name, group_config in DEFAULT_GROUPS.items():
|
||||
if group_name not in existing_groups:
|
||||
group = Group(
|
||||
name=group_name,
|
||||
description=group_config["description"],
|
||||
permissions=group_config["permissions"],
|
||||
is_system=group_config["is_system"],
|
||||
)
|
||||
session.add(group)
|
||||
groups_created.append(group_name)
|
||||
logger.info(f"Created default group: {group_name}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Migrate existing users to groups if they're not already in any group
|
||||
if groups_created:
|
||||
# Get the groups we need
|
||||
admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
|
||||
admin_group = admin_result.scalar_one_or_none()
|
||||
|
||||
operators_result = await session.execute(select(Group).where(Group.name == "Operators"))
|
||||
operators_group = operators_result.scalar_one_or_none()
|
||||
|
||||
# Get all users
|
||||
users_result = await session.execute(select(User))
|
||||
users = users_result.scalars().all()
|
||||
|
||||
for user in users:
|
||||
# Skip if user already has groups
|
||||
if user.groups:
|
||||
continue
|
||||
|
||||
if user.role == "admin" and admin_group:
|
||||
user.groups.append(admin_group)
|
||||
logger.info(f"Migrated admin user '{user.username}' to Administrators group")
|
||||
elif operators_group:
|
||||
user.groups.append(operators_group)
|
||||
logger.info(f"Migrated user '{user.username}' to Operators group")
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -183,6 +183,7 @@ from backend.app.api.routes import (
|
||||
filaments,
|
||||
firmware,
|
||||
github_backup,
|
||||
groups,
|
||||
kprofiles,
|
||||
library,
|
||||
maintenance,
|
||||
@@ -2526,6 +2527,7 @@ app = FastAPI(
|
||||
# API routes
|
||||
app.include_router(auth.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(users.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(groups.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(printers.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(archives.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(filaments.router, prefix=app_settings.api_prefix)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,6 @@ import { ExternalLinkPage } from './pages/ExternalLinkPage';
|
||||
import { SystemInfoPage } from './pages/SystemInfoPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { SetupPage } from './pages/SetupPage';
|
||||
import { UsersPage } from './pages/UsersPage';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
@@ -52,7 +51,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authEnabled, loading, user } = useAuth();
|
||||
const { authEnabled, loading, user, isAdmin } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
@@ -69,7 +68,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
// If user is not admin, redirect to home
|
||||
if (user.role !== 'admin') {
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
@@ -125,7 +124,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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// ========================================================================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1713,6 +1719,82 @@ export interface ExternalLinkUpdate {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// Permission type - all available permissions
|
||||
export type Permission =
|
||||
| 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files'
|
||||
| 'archives:read' | 'archives:create' | 'archives:update' | 'archives:delete' | 'archives:reprint'
|
||||
| 'queue:read' | 'queue:create' | 'queue:update' | 'queue:delete' | 'queue:reorder'
|
||||
| 'library:read' | 'library:upload' | 'library:update' | 'library:delete'
|
||||
| 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
|
||||
| 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
|
||||
| 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
|
||||
| 'camera:view'
|
||||
| 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
|
||||
| 'kprofiles:read' | 'kprofiles:create' | 'kprofiles:update' | 'kprofiles:delete'
|
||||
| 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete'
|
||||
| 'notification_templates:read' | 'notification_templates:update'
|
||||
| 'external_links:read' | 'external_links:create' | 'external_links:update' | 'external_links:delete'
|
||||
| 'discovery:scan'
|
||||
| 'firmware:read' | 'firmware:update'
|
||||
| 'ams_history:read'
|
||||
| 'stats:read'
|
||||
| 'system:read'
|
||||
| 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
|
||||
| 'github:backup' | 'github:restore'
|
||||
| 'cloud:auth'
|
||||
| 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
|
||||
| 'users:read' | 'users:create' | 'users:update' | 'users:delete'
|
||||
| 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
|
||||
| 'websocket:connect';
|
||||
|
||||
// Group types
|
||||
export interface GroupBrief {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
permissions: Permission[];
|
||||
is_system: boolean;
|
||||
user_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GroupDetail extends Group {
|
||||
users: Array<{ id: number; username: string; is_active: boolean }>;
|
||||
}
|
||||
|
||||
export interface GroupCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions: Permission[];
|
||||
}
|
||||
|
||||
export interface GroupUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
permissions?: Permission[];
|
||||
}
|
||||
|
||||
export interface PermissionInfo {
|
||||
value: Permission;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface PermissionCategory {
|
||||
name: string;
|
||||
permissions: PermissionInfo[];
|
||||
}
|
||||
|
||||
export interface PermissionsListResponse {
|
||||
categories: PermissionCategory[];
|
||||
all_permissions: Permission[];
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
@@ -1728,8 +1810,11 @@ export interface LoginResponse {
|
||||
export interface UserResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
role: string; // Deprecated, kept for backward compatibility
|
||||
is_active: boolean;
|
||||
is_admin: boolean; // Computed from role and group membership
|
||||
groups: GroupBrief[];
|
||||
permissions: Permission[]; // All permissions from groups
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -1737,6 +1822,7 @@ export interface UserCreate {
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
group_ids?: number[];
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
@@ -1744,6 +1830,7 @@ export interface UserUpdate {
|
||||
password?: string;
|
||||
role?: string;
|
||||
is_active?: boolean;
|
||||
group_ids?: number[];
|
||||
}
|
||||
|
||||
export interface SetupRequest {
|
||||
@@ -1786,7 +1873,7 @@ export const api = {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Users (admin only)
|
||||
// Users
|
||||
getUsers: () => request<UserResponse[]>('/users/'),
|
||||
getUser: (id: number) => request<UserResponse>(`/users/${id}`),
|
||||
createUser: (data: UserCreate) =>
|
||||
@@ -1803,6 +1890,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/'),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -69,6 +69,7 @@ import { CompareArchivesModal } from '../components/CompareArchivesModal';
|
||||
import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
|
||||
import { TagManagementModal } from '../components/TagManagementModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -122,6 +123,7 @@ function ArchiveCard({
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission } = useAuth();
|
||||
const isMobile = useIsMobile();
|
||||
const [showViewer, setShowViewer] = useState(false);
|
||||
const [showReprint, setShowReprint] = useState(false);
|
||||
@@ -284,11 +286,15 @@ function ArchiveCard({
|
||||
label: 'Print',
|
||||
icon: <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',
|
||||
@@ -335,7 +341,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: () => {} },
|
||||
{
|
||||
@@ -351,22 +358,30 @@ function ArchiveCard({
|
||||
source3mfInputRef.current?.click();
|
||||
}
|
||||
},
|
||||
disabled: !archive.source_3mf_path && !hasPermission('archives:update'),
|
||||
title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined,
|
||||
},
|
||||
...(archive.source_3mf_path ? [{
|
||||
label: 'Replace Source 3MF',
|
||||
icon: <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',
|
||||
@@ -383,6 +398,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: () => {} },
|
||||
{
|
||||
@@ -394,6 +411,8 @@ function ArchiveCard({
|
||||
link.download = `${archive.print_name || archive.filename}.3mf`;
|
||||
link.click();
|
||||
},
|
||||
disabled: !hasPermission('archives:read'),
|
||||
title: !hasPermission('archives:read') ? 'You do not have permission to download archives' : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Copy Download Link',
|
||||
@@ -406,6 +425,8 @@ function ArchiveCard({
|
||||
showToast('Failed to copy link', 'error');
|
||||
});
|
||||
},
|
||||
disabled: !hasPermission('archives:read'),
|
||||
title: !hasPermission('archives:read') ? 'You do not have permission to copy download links' : undefined,
|
||||
},
|
||||
{
|
||||
label: 'QR Code',
|
||||
@@ -428,11 +449,15 @@ function ArchiveCard({
|
||||
label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
|
||||
icon: <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}`,
|
||||
@@ -443,6 +468,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[] = [];
|
||||
|
||||
@@ -452,6 +479,7 @@ function ArchiveCard({
|
||||
label: 'Remove from Project',
|
||||
icon: <X className="w-4 h-4" />,
|
||||
onClick: () => assignProjectMutation.mutate(null),
|
||||
disabled: !hasPermission('archives:update'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -478,7 +506,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'),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -498,6 +526,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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -617,15 +647,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') && (
|
||||
@@ -865,6 +902,8 @@ function ArchiveCard({
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
onClick={() => setShowReprint(true)}
|
||||
disabled={!hasPermission('archives:reprint')}
|
||||
title={!hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined}
|
||||
>
|
||||
<Printer className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="hidden sm:inline">Reprint</span>
|
||||
@@ -947,7 +986,8 @@ function ArchiveCard({
|
||||
size="sm"
|
||||
className="min-w-0 p-1 sm:p-1.5"
|
||||
onClick={() => setShowEdit(true)}
|
||||
title="Edit"
|
||||
disabled={!hasPermission('archives:update')}
|
||||
title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'}
|
||||
>
|
||||
<Pencil className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
</Button>
|
||||
@@ -956,7 +996,8 @@ function ArchiveCard({
|
||||
size="sm"
|
||||
className="min-w-0 p-1 sm:p-1.5"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
title="Delete"
|
||||
disabled={!hasPermission('archives:delete')}
|
||||
title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 sm:w-4 sm:h-4 text-red-400" />
|
||||
</Button>
|
||||
@@ -1213,6 +1254,7 @@ function ArchiveListRow({
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission } = useAuth();
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showReprint, setShowReprint] = useState(false);
|
||||
@@ -1357,11 +1399,15 @@ function ArchiveListRow({
|
||||
label: 'Print',
|
||||
icon: <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',
|
||||
@@ -1408,7 +1454,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: () => {} },
|
||||
{
|
||||
@@ -1424,22 +1471,30 @@ function ArchiveListRow({
|
||||
source3mfInputRef.current?.click();
|
||||
}
|
||||
},
|
||||
disabled: !archive.source_3mf_path && !hasPermission('archives:update'),
|
||||
title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined,
|
||||
},
|
||||
...(archive.source_3mf_path ? [{
|
||||
label: 'Replace Source 3MF',
|
||||
icon: <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',
|
||||
@@ -1456,6 +1511,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: () => {} },
|
||||
{
|
||||
@@ -1467,6 +1524,8 @@ function ArchiveListRow({
|
||||
link.download = `${archive.print_name || archive.filename}.3mf`;
|
||||
link.click();
|
||||
},
|
||||
disabled: !hasPermission('archives:read'),
|
||||
title: !hasPermission('archives:read') ? 'You do not have permission to download archives' : undefined,
|
||||
},
|
||||
{
|
||||
label: 'Copy Download Link',
|
||||
@@ -1479,6 +1538,8 @@ function ArchiveListRow({
|
||||
showToast('Failed to copy link', 'error');
|
||||
});
|
||||
},
|
||||
disabled: !hasPermission('archives:read'),
|
||||
title: !hasPermission('archives:read') ? 'You do not have permission to copy download links' : undefined,
|
||||
},
|
||||
{
|
||||
label: 'QR Code',
|
||||
@@ -1501,11 +1562,15 @@ function ArchiveListRow({
|
||||
label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites',
|
||||
icon: <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}`,
|
||||
@@ -1566,6 +1631,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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1698,7 +1765,8 @@ function ArchiveListRow({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowEdit(true)}
|
||||
title="Edit"
|
||||
disabled={!hasPermission('archives:update')}
|
||||
title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -1706,7 +1774,8 @@ function ArchiveListRow({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
title="Delete"
|
||||
disabled={!hasPermission('archives:delete')}
|
||||
title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
</Button>
|
||||
@@ -1960,6 +2029,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] =
|
||||
export function ArchivesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { hasPermission } = useAuth();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterPrinter, setFilterPrinter] = useState<number | null>(() => {
|
||||
@@ -2375,6 +2445,8 @@ export function ArchivesPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowBatchTag(true)}
|
||||
disabled={!hasPermission('archives:update')}
|
||||
title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
|
||||
>
|
||||
<Tag className="w-4 h-4" />
|
||||
Tags
|
||||
@@ -2383,6 +2455,8 @@ export function ArchivesPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowBatchProject(true)}
|
||||
disabled={!hasPermission('archives:update')}
|
||||
title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined}
|
||||
>
|
||||
<FolderKanban className="w-4 h-4" />
|
||||
Project
|
||||
@@ -2390,6 +2464,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)))
|
||||
@@ -2409,6 +2485,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
|
||||
@@ -2530,7 +2608,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -1133,6 +1135,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({
|
||||
@@ -1575,11 +1578,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
|
||||
@@ -1605,11 +1614,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
|
||||
@@ -1816,18 +1831,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" />
|
||||
@@ -2042,16 +2059,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
|
||||
@@ -2060,18 +2077,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'}
|
||||
@@ -2249,13 +2266,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
|
||||
@@ -2281,7 +2304,7 @@ function PrinterCard({
|
||||
} : undefined,
|
||||
}}
|
||||
configureSlot={{
|
||||
enabled: true,
|
||||
enabled: hasPermission('printers:control'),
|
||||
onConfigure: () => setConfigureSlotModal({
|
||||
amsId: ams.id,
|
||||
trayId: slotIdx,
|
||||
@@ -2298,7 +2321,7 @@ function PrinterCard({
|
||||
) : (
|
||||
<EmptySlotHoverCard
|
||||
configureSlot={{
|
||||
enabled: true,
|
||||
enabled: hasPermission('printers:control'),
|
||||
onConfigure: () => setConfigureSlotModal({
|
||||
amsId: ams.id,
|
||||
trayId: slotIdx,
|
||||
@@ -2432,13 +2455,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
|
||||
@@ -2464,7 +2493,7 @@ function PrinterCard({
|
||||
} : undefined,
|
||||
}}
|
||||
configureSlot={{
|
||||
enabled: true,
|
||||
enabled: hasPermission('printers:control'),
|
||||
onConfigure: () => setConfigureSlotModal({
|
||||
amsId: ams.id,
|
||||
trayId: htSlotId,
|
||||
@@ -2481,7 +2510,7 @@ function PrinterCard({
|
||||
) : (
|
||||
<EmptySlotHoverCard
|
||||
configureSlot={{
|
||||
enabled: true,
|
||||
enabled: hasPermission('printers:control'),
|
||||
onConfigure: () => setConfigureSlotModal({
|
||||
amsId: ams.id,
|
||||
trayId: htSlotId,
|
||||
@@ -2592,7 +2621,7 @@ function PrinterCard({
|
||||
} : undefined,
|
||||
}}
|
||||
configureSlot={{
|
||||
enabled: true,
|
||||
enabled: hasPermission('printers:control'),
|
||||
onConfigure: () => setConfigureSlotModal({
|
||||
amsId: 255, // External spool indicator
|
||||
trayId: 0,
|
||||
@@ -2651,24 +2680,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
|
||||
@@ -2682,12 +2717,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
|
||||
@@ -2736,8 +2773,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" />
|
||||
@@ -2774,8 +2811,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 ? (
|
||||
@@ -2788,8 +2825,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 ? (
|
||||
@@ -2803,7 +2840,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
|
||||
@@ -3369,13 +3407,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>
|
||||
@@ -4063,6 +4101,7 @@ function EditPrinterModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const [form, setForm] = useState({
|
||||
name: printer.name,
|
||||
ip_address: printer.ip_address,
|
||||
@@ -4079,6 +4118,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
|
||||
@@ -4314,6 +4354,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 }>>(() => {
|
||||
@@ -4419,6 +4461,7 @@ export function PrintersPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
|
||||
setShowAddModal(false);
|
||||
},
|
||||
onError: (error: Error) => showToast(error.message || 'Failed to add printer', 'error'),
|
||||
});
|
||||
|
||||
const powerOnMutation = useMutation({
|
||||
@@ -4639,7 +4682,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>
|
||||
@@ -4652,7 +4699,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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1144
-171
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
if (currentUser?.role !== 'admin') {
|
||||
const closeEditModal = () => {
|
||||
setShowEditModal(false);
|
||||
setEditingUserId(null);
|
||||
setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
|
||||
};
|
||||
|
||||
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
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -23,8 +23,8 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-BJQC4Kk-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C1mIxrzF.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>
|
||||
|
||||
Reference in New Issue
Block a user