Merge pull request #201 from maziggy/feature/auth_details

Add group-based permissions system with granular access control
This commit is contained in:
MartinNYHC
2026-01-31 15:15:55 +01:00
committed by GitHub
44 changed files with 13920 additions and 602 deletions
+16
View File
@@ -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
+3 -2
View File
@@ -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>
+45 -18
View File
@@ -3,6 +3,7 @@ from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
@@ -13,9 +14,25 @@ from backend.app.core.auth import (
get_user_by_username,
)
from backend.app.core.database import get_db
from backend.app.models.group import Group
from backend.app.models.settings import Settings
from backend.app.models.user import User
from backend.app.schemas.auth import LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
from backend.app.schemas.auth import GroupBrief, LoginRequest, LoginResponse, SetupRequest, SetupResponse, UserResponse
def _user_to_response(user: User) -> UserResponse:
"""Convert a User model to UserResponse schema."""
return UserResponse(
id=user.id,
username=user.username,
role=user.role,
is_active=user.is_active,
is_admin=user.is_admin,
groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
permissions=sorted(user.get_permissions()),
created_at=user.created_at.isoformat(),
)
router = APIRouter(prefix="/auth", tags=["authentication"])
@@ -126,6 +143,14 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
role="admin",
is_active=True,
)
# Try to add user to Administrators group if it exists
admin_group_result = await db.execute(select(Group).where(Group.name == "Administrators"))
admin_group = admin_group_result.scalar_one_or_none()
if admin_group:
admin_user.groups.append(admin_group)
logger.info("Added new admin user to Administrators group")
db.add(admin_user)
logger.info(f"Admin user added to session: {request.admin_username}")
admin_created = True
@@ -179,8 +204,12 @@ async def disable_auth(
logger = logging.getLogger(__name__)
# Reload user with groups for proper is_admin check
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
# Only admins can disable authentication
if current_user.role != "admin":
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can disable authentication",
@@ -189,7 +218,7 @@ async def disable_auth(
try:
await set_auth_enabled(db, False)
await db.commit()
logger.info(f"Authentication disabled by admin user: {current_user.username}")
logger.info(f"Authentication disabled by admin user: {user.username}")
return {"message": "Authentication disabled successfully", "auth_enabled": False}
except Exception as e:
await db.rollback()
@@ -219,32 +248,30 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
headers={"WWW-Authenticate": "Bearer"},
)
# Reload user with groups for proper permission calculation
result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
user = result.scalar_one()
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
return LoginResponse(
access_token=access_token,
token_type="bearer",
user=UserResponse(
id=user.id,
username=user.username,
role=user.role,
is_active=user.is_active,
created_at=user.created_at.isoformat(),
),
user=_user_to_response(user),
)
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: User = Depends(get_current_active_user)):
async def get_current_user_info(
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db),
):
"""Get current user information."""
return UserResponse(
id=current_user.id,
username=current_user.username,
role=current_user.role,
is_active=current_user.is_active,
created_at=current_user.created_at.isoformat(),
)
# Reload user with groups for proper permission calculation
result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
user = result.scalar_one()
return _user_to_response(user)
@router.post("/logout")
+316
View File
@@ -0,0 +1,316 @@
"""Group management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import (
ALL_PERMISSIONS,
PERMISSION_CATEGORIES,
Permission,
)
from backend.app.models.group import Group
from backend.app.models.user import User
from backend.app.schemas.group import (
GroupCreate,
GroupDetailResponse,
GroupResponse,
GroupUpdate,
PermissionCategory,
PermissionInfo,
PermissionsListResponse,
UserBrief,
)
router = APIRouter(prefix="/groups", tags=["groups"])
def _permission_label(perm: Permission) -> str:
"""Convert permission enum to human-readable label."""
# e.g., "printers:read" -> "Read Printers"
parts = perm.value.split(":")
if len(parts) == 2:
resource, action = parts
resource = resource.replace("_", " ").title()
action = action.title()
return f"{action} {resource}"
return perm.value
@router.get("/permissions", response_model=PermissionsListResponse)
async def list_permissions(
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
):
"""List all available permissions organized by category."""
categories = []
for name, perms in PERMISSION_CATEGORIES.items():
categories.append(
PermissionCategory(
name=name,
permissions=[PermissionInfo(value=p.value, label=_permission_label(p)) for p in perms],
)
)
return PermissionsListResponse(
categories=categories,
all_permissions=ALL_PERMISSIONS,
)
@router.get("", response_model=list[GroupResponse])
@router.get("/", response_model=list[GroupResponse])
async def list_groups(
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
db: AsyncSession = Depends(get_db),
):
"""List all groups."""
result = await db.execute(select(Group).options(selectinload(Group.users)).order_by(Group.name))
groups = result.scalars().all()
return [
GroupResponse(
id=group.id,
name=group.name,
description=group.description,
permissions=group.permissions or [],
is_system=group.is_system,
user_count=len(group.users),
created_at=group.created_at,
updated_at=group.updated_at,
)
for group in groups
]
@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
@router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
async def create_group(
group_data: GroupCreate,
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_CREATE),
db: AsyncSession = Depends(get_db),
):
"""Create a new group."""
# Check if group name already exists
existing = await db.execute(select(Group).where(Group.name == group_data.name))
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Group name already exists",
)
# Validate permissions
invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
if invalid_perms:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid permissions: {', '.join(invalid_perms)}",
)
group = Group(
name=group_data.name,
description=group_data.description,
permissions=group_data.permissions,
is_system=False, # User-created groups are not system groups
)
db.add(group)
await db.commit()
await db.refresh(group)
return GroupResponse(
id=group.id,
name=group.name,
description=group.description,
permissions=group.permissions or [],
is_system=group.is_system,
user_count=0,
created_at=group.created_at,
updated_at=group.updated_at,
)
@router.get("/{group_id}", response_model=GroupDetailResponse)
async def get_group(
group_id: int,
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get a group by ID with user list."""
result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
group = result.scalar_one_or_none()
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
return GroupDetailResponse(
id=group.id,
name=group.name,
description=group.description,
permissions=group.permissions or [],
is_system=group.is_system,
user_count=len(group.users),
created_at=group.created_at,
updated_at=group.updated_at,
users=[UserBrief(id=u.id, username=u.username, is_active=u.is_active) for u in group.users],
)
@router.patch("/{group_id}", response_model=GroupResponse)
async def update_group(
group_id: int,
group_data: GroupUpdate,
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Update a group."""
result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
group = result.scalar_one_or_none()
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
# Check if updating name to one that already exists
if group_data.name is not None and group_data.name != group.name:
existing = await db.execute(select(Group).where(Group.name == group_data.name, Group.id != group_id))
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Group name already exists",
)
# System groups cannot have their name changed
if group.is_system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot rename system groups",
)
group.name = group_data.name
if group_data.description is not None:
group.description = group_data.description
if group_data.permissions is not None:
# Validate permissions
invalid_perms = [p for p in group_data.permissions if p not in ALL_PERMISSIONS]
if invalid_perms:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid permissions: {', '.join(invalid_perms)}",
)
group.permissions = group_data.permissions
await db.commit()
await db.refresh(group)
return GroupResponse(
id=group.id,
name=group.name,
description=group.description,
permissions=group.permissions or [],
is_system=group.is_system,
user_count=len(group.users),
created_at=group.created_at,
updated_at=group.updated_at,
)
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_group(
group_id: int,
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_DELETE),
db: AsyncSession = Depends(get_db),
):
"""Delete a group (non-system groups only)."""
result = await db.execute(select(Group).where(Group.id == group_id))
group = result.scalar_one_or_none()
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
if group.is_system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete system groups",
)
await db.delete(group)
await db.commit()
@router.post("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def add_user_to_group(
group_id: int,
user_id: int,
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Add a user to a group."""
# Get group with users
result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
group = result.scalar_one_or_none()
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
# Get user
user_result = await db.execute(select(User).where(User.id == user_id))
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check if user is already in group
if user in group.users:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User is already in this group",
)
group.users.append(user)
await db.commit()
@router.delete("/{group_id}/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_user_from_group(
group_id: int,
user_id: int,
_: User | None = RequirePermissionIfAuthEnabled(Permission.GROUPS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Remove a user from a group."""
# Get group with users
result = await db.execute(select(Group).where(Group.id == group_id).options(selectinload(Group.users)))
group = result.scalar_one_or_none()
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
# Get user
user_result = await db.execute(select(User).where(User.id == user_id))
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
# Check if user is in group
if user not in group.users:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User is not in this group",
)
group.users.remove(user)
await db.commit()
+91 -18
View File
@@ -8,9 +8,10 @@ from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequireAdminIfAuthEnabled
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.config import settings
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.printer import Printer
from backend.app.models.slot_preset import SlotPresetMapping
from backend.app.schemas.printer import (
@@ -38,7 +39,10 @@ router = APIRouter(prefix="/printers", tags=["printers"])
@router.get("/", response_model=list[PrinterResponse])
async def list_printers(db: AsyncSession = Depends(get_db)):
async def list_printers(
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""List all configured printers."""
result = await db.execute(select(Printer).order_by(Printer.name))
return list(result.scalars().all())
@@ -47,8 +51,8 @@ async def list_printers(db: AsyncSession = Depends(get_db)):
@router.post("/", response_model=PrinterResponse)
async def create_printer(
printer_data: PrinterCreate,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
db: AsyncSession = Depends(get_db),
_current_user=RequireAdminIfAuthEnabled(),
):
"""Add a new printer."""
# Check if serial number already exists
@@ -69,7 +73,9 @@ async def create_printer(
@router.get("/usb-cameras")
async def list_usb_cameras():
async def list_usb_cameras(
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
):
"""List available USB cameras connected to the system.
Returns a list of detected V4L2 video devices with their info.
@@ -85,7 +91,11 @@ async def list_usb_cameras():
@router.get("/{printer_id}", response_model=PrinterResponse)
async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
async def get_printer(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get a specific printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -98,8 +108,8 @@ async def get_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
async def update_printer(
printer_id: int,
printer_data: PrinterUpdate,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
db: AsyncSession = Depends(get_db),
_current_user=RequireAdminIfAuthEnabled(),
):
"""Update a printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
@@ -143,8 +153,8 @@ async def update_printer(
async def delete_printer(
printer_id: int,
delete_archives: bool = True,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_DELETE),
db: AsyncSession = Depends(get_db),
_current_user=RequireAdminIfAuthEnabled(),
):
"""Delete a printer.
@@ -191,7 +201,11 @@ async def delete_printer(
@router.get("/{printer_id}/status", response_model=PrinterStatus)
async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
async def get_printer_status(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get real-time status of a printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -431,7 +445,11 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
@router.post("/{printer_id}/refresh-status")
async def refresh_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)):
async def refresh_printer_status(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Request a full status refresh from the printer (sends pushall command)."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -446,7 +464,11 @@ async def refresh_printer_status(printer_id: int, db: AsyncSession = Depends(get
@router.post("/{printer_id}/connect")
async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
async def connect_printer(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Manually connect to a printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -458,7 +480,11 @@ async def connect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
@router.post("/{printer_id}/disconnect")
async def disconnect_printer(printer_id: int, db: AsyncSession = Depends(get_db)):
async def disconnect_printer(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Manually disconnect from a printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -474,6 +500,7 @@ async def test_printer_connection(
ip_address: str,
serial_number: str,
access_code: str,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
):
"""Test connection to a printer without saving."""
result = await printer_manager.test_connection(
@@ -492,6 +519,7 @@ _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
async def get_printer_cover(
printer_id: int,
view: str | None = None,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get the cover image for the current print job.
@@ -679,6 +707,7 @@ async def get_printer_cover(
async def list_printer_files(
printer_id: int,
path: str = "/",
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
db: AsyncSession = Depends(get_db),
):
"""List files on the printer at the specified path."""
@@ -703,6 +732,7 @@ async def list_printer_files(
async def download_printer_file(
printer_id: int,
path: str,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
db: AsyncSession = Depends(get_db),
):
"""Download a file from the printer."""
@@ -743,6 +773,7 @@ async def download_printer_file(
async def download_printer_files_as_zip(
printer_id: int,
request: dict,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
db: AsyncSession = Depends(get_db),
):
"""Download multiple files from the printer as a ZIP archive."""
@@ -787,6 +818,7 @@ async def download_printer_files_as_zip(
async def delete_printer_file(
printer_id: int,
path: str,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_FILES),
db: AsyncSession = Depends(get_db),
):
"""Delete a file from the printer."""
@@ -805,6 +837,7 @@ async def delete_printer_file(
@router.get("/{printer_id}/storage")
async def get_printer_storage(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get storage information from the printer."""
@@ -824,7 +857,11 @@ async def get_printer_storage(
@router.post("/{printer_id}/logging/enable")
async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
async def enable_mqtt_logging(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Enable MQTT message logging for a printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -839,7 +876,11 @@ async def enable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db
@router.post("/{printer_id}/logging/disable")
async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_db)):
async def disable_mqtt_logging(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Disable MQTT message logging for a printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -854,7 +895,11 @@ async def disable_mqtt_logging(printer_id: int, db: AsyncSession = Depends(get_d
@router.get("/{printer_id}/logging")
async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
async def get_mqtt_logs(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get MQTT message logs for a printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -877,7 +922,11 @@ async def get_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
@router.delete("/{printer_id}/logging")
async def clear_mqtt_logs(printer_id: int, db: AsyncSession = Depends(get_db)):
async def clear_mqtt_logs(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Clear MQTT message logs for a printer."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -900,6 +949,7 @@ async def set_print_option(
enabled: bool,
print_halt: bool = True,
sensitivity: str = "medium",
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Set an AI detection / print option on the printer.
@@ -972,6 +1022,7 @@ async def start_calibration(
motor_noise: bool = False,
nozzle_offset: bool = False,
high_temp_heatbed: bool = False,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Start printer calibration with selected options.
@@ -1027,6 +1078,7 @@ async def start_calibration(
@router.get("/{printer_id}/slot-presets")
async def get_slot_presets(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get all saved slot-to-preset mappings for a printer."""
@@ -1049,6 +1101,7 @@ async def get_slot_preset(
printer_id: int,
ams_id: int,
tray_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get the saved preset for a specific slot."""
@@ -1079,6 +1132,7 @@ async def save_slot_preset(
tray_id: int,
preset_id: str,
preset_name: str,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Save a preset mapping for a specific slot."""
@@ -1128,6 +1182,7 @@ async def delete_slot_preset(
printer_id: int,
ams_id: int,
tray_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_UPDATE),
db: AsyncSession = Depends(get_db),
):
"""Delete a saved preset mapping for a slot."""
@@ -1164,6 +1219,7 @@ async def configure_ams_slot(
kprofile_filament_id: str = Query(""),
kprofile_setting_id: str = Query(""),
k_value: float = Query(0.0),
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
):
"""Configure an AMS slot with a specific filament setting and K profile.
@@ -1363,7 +1419,11 @@ async def debug_simulate_print_complete(
@router.post("/{printer_id}/print/stop")
async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)):
async def stop_print(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Stop/cancel the current print job."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -1382,7 +1442,11 @@ async def stop_print(printer_id: int, db: AsyncSession = Depends(get_db)):
@router.post("/{printer_id}/print/pause")
async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)):
async def pause_print(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Pause the current print job."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -1401,7 +1465,11 @@ async def pause_print(printer_id: int, db: AsyncSession = Depends(get_db)):
@router.post("/{printer_id}/print/resume")
async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
async def resume_print(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Resume a paused print job."""
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
@@ -1423,6 +1491,7 @@ async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
async def set_chamber_light(
printer_id: int,
on: bool = Query(..., description="True to turn on, False to turn off"),
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Turn the chamber light on or off."""
@@ -1446,6 +1515,7 @@ async def set_chamber_light(
async def get_printable_objects(
printer_id: int,
reload: bool = False,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Get the list of printable objects for the current print.
@@ -1543,6 +1613,7 @@ async def get_printable_objects(
async def skip_objects(
printer_id: int,
object_ids: list[int],
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Skip specific objects during the current print.
@@ -1597,6 +1668,7 @@ async def refresh_ams_slot(
printer_id: int,
ams_id: int,
slot_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
db: AsyncSession = Depends(get_db),
):
"""Re-read RFID for an AMS slot (triggers filament info refresh)."""
@@ -1619,6 +1691,7 @@ async def refresh_ams_slot(
@router.get("/{printer_id}/runtime-debug")
async def get_runtime_debug(
printer_id: int,
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
db: AsyncSession = Depends(get_db),
):
"""Debug endpoint: Get runtime tracking status for a printer."""
+63
View File
@@ -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
View File
@@ -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
View File
@@ -11,8 +11,10 @@ from jwt.exceptions import PyJWTError as JWTError
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.database import async_session, get_db
from backend.app.core.permissions import Permission
from backend.app.models.api_key import APIKey
from backend.app.models.settings import Settings
from backend.app.models.user import User
@@ -60,8 +62,8 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> s
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
"""Get a user by username."""
result = await db.execute(select(User).where(User.username == username))
"""Get a user by username with groups loaded for permission checks."""
result = await db.execute(select(User).where(User.username == username).options(selectinload(User.groups)))
return result.scalar_one_or_none()
@@ -347,3 +349,124 @@ def RequireAdmin():
def RequireAdminIfAuthEnabled():
"""Dependency that requires admin role if auth is enabled."""
return Depends(require_admin_if_auth_enabled())
def require_permission(*permissions: str | Permission):
"""Dependency factory that requires user to have ALL specified permissions.
Args:
*permissions: Permission strings or Permission enum values to require
Returns:
A dependency function that validates permissions
"""
# Convert Permission enums to strings
perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
async def permission_checker(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if credentials is None:
raise credentials_exception
try:
token = credentials.credentials
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
async with async_session() as db:
user = await get_user_by_username(db, username)
if user is None or not user.is_active:
raise credentials_exception
if not user.has_all_permissions(*perm_strings):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permissions: {', '.join(perm_strings)}",
)
return user
return permission_checker
def require_permission_if_auth_enabled(*permissions: str | Permission):
"""Dependency factory that checks permissions only if auth is enabled.
This provides backward compatibility - when auth is disabled, all access is allowed.
Args:
*permissions: Permission strings or Permission enum values to require
Returns:
A dependency function that validates permissions if auth is enabled
"""
# Convert Permission enums to strings
perm_strings = [p.value if isinstance(p, Permission) else p for p in permissions]
async def permission_checker(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
) -> User | None:
async with async_session() as db:
auth_enabled = await is_auth_enabled(db)
if not auth_enabled:
return None # Auth disabled, allow access
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
try:
token = credentials.credentials
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user = await get_user_by_username(db, username)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.has_all_permissions(*perm_strings):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permissions: {', '.join(perm_strings)}",
)
return user
return permission_checker
def RequirePermission(*permissions: str | Permission):
"""Convenience dependency that requires ALL specified permissions."""
return Depends(require_permission(*permissions))
def RequirePermissionIfAuthEnabled(*permissions: str | Permission):
"""Convenience dependency that requires permissions if auth is enabled."""
return Depends(require_permission_if_auth_enabled(*permissions))
+106
View File
@@ -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()
+392
View File
@@ -0,0 +1,392 @@
"""Permission definitions for the group-based access control system.
This module defines all permissions using a string enum with `resource:action` naming.
Permissions are additive across groups - a user has all permissions from all their groups.
"""
from enum import Enum
class Permission(str, Enum):
"""All available permissions in the system.
Permissions follow the pattern: resource:action
Actions typically include: read, create, update, delete, plus resource-specific actions.
"""
# Printers
PRINTERS_READ = "printers:read"
PRINTERS_CREATE = "printers:create"
PRINTERS_UPDATE = "printers:update"
PRINTERS_DELETE = "printers:delete"
PRINTERS_CONTROL = "printers:control" # Start/stop/pause/resume prints
PRINTERS_FILES = "printers:files" # Send files to printer
# Archives
ARCHIVES_READ = "archives:read"
ARCHIVES_CREATE = "archives:create"
ARCHIVES_UPDATE = "archives:update"
ARCHIVES_DELETE = "archives:delete"
ARCHIVES_REPRINT = "archives:reprint" # Reprint from archive
# Queue
QUEUE_READ = "queue:read"
QUEUE_CREATE = "queue:create"
QUEUE_UPDATE = "queue:update"
QUEUE_DELETE = "queue:delete"
QUEUE_REORDER = "queue:reorder"
# Library
LIBRARY_READ = "library:read"
LIBRARY_UPLOAD = "library:upload"
LIBRARY_UPDATE = "library:update"
LIBRARY_DELETE = "library:delete"
# Projects
PROJECTS_READ = "projects:read"
PROJECTS_CREATE = "projects:create"
PROJECTS_UPDATE = "projects:update"
PROJECTS_DELETE = "projects:delete"
# Filaments
FILAMENTS_READ = "filaments:read"
FILAMENTS_CREATE = "filaments:create"
FILAMENTS_UPDATE = "filaments:update"
FILAMENTS_DELETE = "filaments:delete"
# Smart Plugs
SMART_PLUGS_READ = "smart_plugs:read"
SMART_PLUGS_CREATE = "smart_plugs:create"
SMART_PLUGS_UPDATE = "smart_plugs:update"
SMART_PLUGS_DELETE = "smart_plugs:delete"
SMART_PLUGS_CONTROL = "smart_plugs:control" # Turn on/off
# Camera
CAMERA_VIEW = "camera:view"
# Maintenance
MAINTENANCE_READ = "maintenance:read"
MAINTENANCE_CREATE = "maintenance:create"
MAINTENANCE_UPDATE = "maintenance:update"
MAINTENANCE_DELETE = "maintenance:delete"
# K-Profiles
KPROFILES_READ = "kprofiles:read"
KPROFILES_CREATE = "kprofiles:create"
KPROFILES_UPDATE = "kprofiles:update"
KPROFILES_DELETE = "kprofiles:delete"
# Notifications
NOTIFICATIONS_READ = "notifications:read"
NOTIFICATIONS_CREATE = "notifications:create"
NOTIFICATIONS_UPDATE = "notifications:update"
NOTIFICATIONS_DELETE = "notifications:delete"
# Notification Templates
NOTIFICATION_TEMPLATES_READ = "notification_templates:read"
NOTIFICATION_TEMPLATES_UPDATE = "notification_templates:update"
# External Links
EXTERNAL_LINKS_READ = "external_links:read"
EXTERNAL_LINKS_CREATE = "external_links:create"
EXTERNAL_LINKS_UPDATE = "external_links:update"
EXTERNAL_LINKS_DELETE = "external_links:delete"
# Discovery (network scanning)
DISCOVERY_SCAN = "discovery:scan"
# Firmware
FIRMWARE_READ = "firmware:read"
FIRMWARE_UPDATE = "firmware:update"
# AMS History
AMS_HISTORY_READ = "ams_history:read"
# Stats/Metrics
STATS_READ = "stats:read"
# System Info
SYSTEM_READ = "system:read"
# Settings (admin-level)
SETTINGS_READ = "settings:read"
SETTINGS_UPDATE = "settings:update"
SETTINGS_BACKUP = "settings:backup"
SETTINGS_RESTORE = "settings:restore"
# GitHub Backup (admin-level)
GITHUB_BACKUP = "github:backup"
GITHUB_RESTORE = "github:restore"
# Cloud Auth (admin-level)
CLOUD_AUTH = "cloud:auth"
# API Keys (admin-level)
API_KEYS_READ = "api_keys:read"
API_KEYS_CREATE = "api_keys:create"
API_KEYS_UPDATE = "api_keys:update"
API_KEYS_DELETE = "api_keys:delete"
# Users (admin-level)
USERS_READ = "users:read"
USERS_CREATE = "users:create"
USERS_UPDATE = "users:update"
USERS_DELETE = "users:delete"
# Groups (admin-level)
GROUPS_READ = "groups:read"
GROUPS_CREATE = "groups:create"
GROUPS_UPDATE = "groups:update"
GROUPS_DELETE = "groups:delete"
# WebSocket connection
WEBSOCKET_CONNECT = "websocket:connect"
# Permission categories for UI organization
PERMISSION_CATEGORIES = {
"Printers": [
Permission.PRINTERS_READ,
Permission.PRINTERS_CREATE,
Permission.PRINTERS_UPDATE,
Permission.PRINTERS_DELETE,
Permission.PRINTERS_CONTROL,
Permission.PRINTERS_FILES,
],
"Archives": [
Permission.ARCHIVES_READ,
Permission.ARCHIVES_CREATE,
Permission.ARCHIVES_UPDATE,
Permission.ARCHIVES_DELETE,
Permission.ARCHIVES_REPRINT,
],
"Queue": [
Permission.QUEUE_READ,
Permission.QUEUE_CREATE,
Permission.QUEUE_UPDATE,
Permission.QUEUE_DELETE,
Permission.QUEUE_REORDER,
],
"Library": [
Permission.LIBRARY_READ,
Permission.LIBRARY_UPLOAD,
Permission.LIBRARY_UPDATE,
Permission.LIBRARY_DELETE,
],
"Projects": [
Permission.PROJECTS_READ,
Permission.PROJECTS_CREATE,
Permission.PROJECTS_UPDATE,
Permission.PROJECTS_DELETE,
],
"Filaments": [
Permission.FILAMENTS_READ,
Permission.FILAMENTS_CREATE,
Permission.FILAMENTS_UPDATE,
Permission.FILAMENTS_DELETE,
],
"Smart Plugs": [
Permission.SMART_PLUGS_READ,
Permission.SMART_PLUGS_CREATE,
Permission.SMART_PLUGS_UPDATE,
Permission.SMART_PLUGS_DELETE,
Permission.SMART_PLUGS_CONTROL,
],
"Camera": [
Permission.CAMERA_VIEW,
],
"Maintenance": [
Permission.MAINTENANCE_READ,
Permission.MAINTENANCE_CREATE,
Permission.MAINTENANCE_UPDATE,
Permission.MAINTENANCE_DELETE,
],
"K-Profiles": [
Permission.KPROFILES_READ,
Permission.KPROFILES_CREATE,
Permission.KPROFILES_UPDATE,
Permission.KPROFILES_DELETE,
],
"Notifications": [
Permission.NOTIFICATIONS_READ,
Permission.NOTIFICATIONS_CREATE,
Permission.NOTIFICATIONS_UPDATE,
Permission.NOTIFICATIONS_DELETE,
Permission.NOTIFICATION_TEMPLATES_READ,
Permission.NOTIFICATION_TEMPLATES_UPDATE,
],
"External Links": [
Permission.EXTERNAL_LINKS_READ,
Permission.EXTERNAL_LINKS_CREATE,
Permission.EXTERNAL_LINKS_UPDATE,
Permission.EXTERNAL_LINKS_DELETE,
],
"Discovery": [
Permission.DISCOVERY_SCAN,
],
"Firmware": [
Permission.FIRMWARE_READ,
Permission.FIRMWARE_UPDATE,
],
"Stats & History": [
Permission.AMS_HISTORY_READ,
Permission.STATS_READ,
],
"System": [
Permission.SYSTEM_READ,
],
"Settings": [
Permission.SETTINGS_READ,
Permission.SETTINGS_UPDATE,
Permission.SETTINGS_BACKUP,
Permission.SETTINGS_RESTORE,
],
"Backup": [
Permission.GITHUB_BACKUP,
Permission.GITHUB_RESTORE,
],
"Cloud": [
Permission.CLOUD_AUTH,
],
"API Keys": [
Permission.API_KEYS_READ,
Permission.API_KEYS_CREATE,
Permission.API_KEYS_UPDATE,
Permission.API_KEYS_DELETE,
],
"User Management": [
Permission.USERS_READ,
Permission.USERS_CREATE,
Permission.USERS_UPDATE,
Permission.USERS_DELETE,
Permission.GROUPS_READ,
Permission.GROUPS_CREATE,
Permission.GROUPS_UPDATE,
Permission.GROUPS_DELETE,
],
"WebSocket": [
Permission.WEBSOCKET_CONNECT,
],
}
# All permissions as a list
ALL_PERMISSIONS = [p.value for p in Permission]
# Default group definitions
DEFAULT_GROUPS = {
"Administrators": {
"description": "Full access to all features and settings",
"permissions": ALL_PERMISSIONS, # All permissions
"is_system": True,
},
"Operators": {
"description": "Can control printers, manage queue and archives, view settings",
"permissions": [
# Printers - full control
Permission.PRINTERS_READ.value,
Permission.PRINTERS_CREATE.value,
Permission.PRINTERS_UPDATE.value,
Permission.PRINTERS_DELETE.value,
Permission.PRINTERS_CONTROL.value,
Permission.PRINTERS_FILES.value,
# Archives - full access
Permission.ARCHIVES_READ.value,
Permission.ARCHIVES_CREATE.value,
Permission.ARCHIVES_UPDATE.value,
Permission.ARCHIVES_DELETE.value,
Permission.ARCHIVES_REPRINT.value,
# Queue - full access
Permission.QUEUE_READ.value,
Permission.QUEUE_CREATE.value,
Permission.QUEUE_UPDATE.value,
Permission.QUEUE_DELETE.value,
Permission.QUEUE_REORDER.value,
# Library - full access
Permission.LIBRARY_READ.value,
Permission.LIBRARY_UPLOAD.value,
Permission.LIBRARY_UPDATE.value,
Permission.LIBRARY_DELETE.value,
# Projects - full access
Permission.PROJECTS_READ.value,
Permission.PROJECTS_CREATE.value,
Permission.PROJECTS_UPDATE.value,
Permission.PROJECTS_DELETE.value,
# Filaments - full access
Permission.FILAMENTS_READ.value,
Permission.FILAMENTS_CREATE.value,
Permission.FILAMENTS_UPDATE.value,
Permission.FILAMENTS_DELETE.value,
# Smart Plugs - full access
Permission.SMART_PLUGS_READ.value,
Permission.SMART_PLUGS_CREATE.value,
Permission.SMART_PLUGS_UPDATE.value,
Permission.SMART_PLUGS_DELETE.value,
Permission.SMART_PLUGS_CONTROL.value,
# Camera - view
Permission.CAMERA_VIEW.value,
# Maintenance - full access
Permission.MAINTENANCE_READ.value,
Permission.MAINTENANCE_CREATE.value,
Permission.MAINTENANCE_UPDATE.value,
Permission.MAINTENANCE_DELETE.value,
# K-Profiles - full access
Permission.KPROFILES_READ.value,
Permission.KPROFILES_CREATE.value,
Permission.KPROFILES_UPDATE.value,
Permission.KPROFILES_DELETE.value,
# Notifications - full access
Permission.NOTIFICATIONS_READ.value,
Permission.NOTIFICATIONS_CREATE.value,
Permission.NOTIFICATIONS_UPDATE.value,
Permission.NOTIFICATIONS_DELETE.value,
Permission.NOTIFICATION_TEMPLATES_READ.value,
Permission.NOTIFICATION_TEMPLATES_UPDATE.value,
# External Links - full access
Permission.EXTERNAL_LINKS_READ.value,
Permission.EXTERNAL_LINKS_CREATE.value,
Permission.EXTERNAL_LINKS_UPDATE.value,
Permission.EXTERNAL_LINKS_DELETE.value,
# Discovery
Permission.DISCOVERY_SCAN.value,
# Firmware - read only
Permission.FIRMWARE_READ.value,
# Stats & History
Permission.AMS_HISTORY_READ.value,
Permission.STATS_READ.value,
Permission.SYSTEM_READ.value,
# Settings - read only
Permission.SETTINGS_READ.value,
# WebSocket
Permission.WEBSOCKET_CONNECT.value,
],
"is_system": True,
},
"Viewers": {
"description": "Read-only access to printers, archives, and queue",
"permissions": [
# Read-only access
Permission.PRINTERS_READ.value,
Permission.ARCHIVES_READ.value,
Permission.QUEUE_READ.value,
Permission.LIBRARY_READ.value,
Permission.PROJECTS_READ.value,
Permission.FILAMENTS_READ.value,
Permission.SMART_PLUGS_READ.value,
Permission.CAMERA_VIEW.value,
Permission.MAINTENANCE_READ.value,
Permission.KPROFILES_READ.value,
Permission.NOTIFICATIONS_READ.value,
Permission.NOTIFICATION_TEMPLATES_READ.value,
Permission.EXTERNAL_LINKS_READ.value,
Permission.FIRMWARE_READ.value,
Permission.AMS_HISTORY_READ.value,
Permission.STATS_READ.value,
Permission.SYSTEM_READ.value,
Permission.SETTINGS_READ.value,
Permission.WEBSOCKET_CONNECT.value,
],
"is_system": True,
},
}
+2
View File
@@ -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
View File
@@ -3,6 +3,7 @@ from backend.app.models.api_key import APIKey
from backend.app.models.archive import PrintArchive
from backend.app.models.filament import Filament
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
from backend.app.models.group import Group, user_groups
from backend.app.models.kprofile_note import KProfileNote
from backend.app.models.library import LibraryFile, LibraryFolder
from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
@@ -34,6 +35,8 @@ __all__ = [
"LibraryFolder",
"LibraryFile",
"User",
"Group",
"user_groups",
"GitHubBackupConfig",
"GitHubBackupLog",
]
+54
View File
@@ -0,0 +1,54 @@
"""Group model for permission-based access control."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON
from backend.app.core.database import Base
if TYPE_CHECKING:
from backend.app.models.user import User
# Many-to-many association table between users and groups
user_groups = Table(
"user_groups",
Base.metadata,
Column("user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("group_id", Integer, ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
)
class Group(Base):
"""Group model for organizing users and assigning permissions.
Groups contain a list of permissions that are granted to all members.
Users can belong to multiple groups, and their permissions are additive.
System groups (Administrators, Operators, Viewers) cannot be deleted.
"""
__tablename__ = "groups"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
permissions: Mapped[list[str]] = mapped_column(JSON, default=list)
is_system: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationship to users through association table
users: Mapped[list[User]] = relationship(
"User",
secondary=user_groups,
back_populates="groups",
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Group {self.name}>"
+82 -3
View File
@@ -1,20 +1,99 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
if TYPE_CHECKING:
from backend.app.models.group import Group
class User(Base):
"""User model for authentication and authorization."""
"""User model for authentication and authorization.
Users can belong to multiple groups, and their permissions are additive
across all groups. The legacy 'role' field is kept for backward compatibility
but is_admin property now also considers group membership.
"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(20), default="user") # "admin" or "user"
role: Mapped[str] = mapped_column(
String(20), default="user"
) # "admin" or "user" (legacy, kept for backward compat)
is_active: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
# Relationship to groups through association table
groups: Mapped[list[Group]] = relationship(
"Group",
secondary="user_groups",
back_populates="users",
lazy="selectin",
)
@property
def is_admin(self) -> bool:
"""Check if user is an admin.
Returns True if:
- User has legacy role='admin', OR
- User belongs to the Administrators group
"""
if self.role == "admin":
return True
return any(g.name == "Administrators" for g in self.groups)
def get_permissions(self) -> set[str]:
"""Get all permissions from all groups the user belongs to.
Returns a set of permission strings. Permissions are additive across groups.
"""
permissions: set[str] = set()
for group in self.groups:
if group.permissions:
permissions.update(group.permissions)
return permissions
def has_permission(self, permission: str) -> bool:
"""Check if user has a specific permission.
Admins have all permissions. For other users, checks if the permission
exists in any of their groups.
"""
if self.is_admin:
return True
return permission in self.get_permissions()
def has_all_permissions(self, *permissions: str) -> bool:
"""Check if user has ALL specified permissions.
Admins have all permissions. For other users, checks if all permissions
exist in their combined group permissions.
"""
if self.is_admin:
return True
user_permissions = self.get_permissions()
return all(p in user_permissions for p in permissions)
def has_any_permission(self, *permissions: str) -> bool:
"""Check if user has ANY of the specified permissions.
Admins have all permissions. For other users, checks if at least one
permission exists in their combined group permissions.
"""
if self.is_admin:
return True
user_permissions = self.get_permissions()
return any(p in user_permissions for p in permissions)
def __repr__(self) -> str:
return f"<User {self.username}>"
+21 -1
View File
@@ -1,6 +1,16 @@
from pydantic import BaseModel
class GroupBrief(BaseModel):
"""Brief group info for embedding in user responses."""
id: int
name: str
class Config:
from_attributes = True
class LoginRequest(BaseModel):
username: str
password: str
@@ -16,6 +26,7 @@ class UserCreate(BaseModel):
username: str
password: str
role: str = "user"
group_ids: list[int] | None = None
class UserUpdate(BaseModel):
@@ -23,19 +34,28 @@ class UserUpdate(BaseModel):
password: str | None = None
role: str | None = None
is_active: bool | None = None
group_ids: list[int] | None = None
class UserResponse(BaseModel):
id: int
username: str
role: str
role: str # Deprecated, kept for backward compatibility
is_active: bool
is_admin: bool # Computed from role and group membership
groups: list[GroupBrief] = []
permissions: list[str] = [] # All permissions from groups
created_at: str
class Config:
from_attributes = True
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class SetupRequest(BaseModel):
auth_enabled: bool
admin_username: str | None = None
+89
View File
@@ -0,0 +1,89 @@
"""Pydantic schemas for Group CRUD operations."""
from datetime import datetime
from pydantic import BaseModel
class GroupBrief(BaseModel):
"""Brief group info for embedding in other responses."""
id: int
name: str
class Config:
from_attributes = True
class GroupCreate(BaseModel):
"""Schema for creating a new group."""
name: str
description: str | None = None
permissions: list[str] = []
class GroupUpdate(BaseModel):
"""Schema for updating a group."""
name: str | None = None
description: str | None = None
permissions: list[str] | None = None
class GroupResponse(BaseModel):
"""Schema for group response."""
id: int
name: str
description: str | None
permissions: list[str]
is_system: bool
user_count: int = 0
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class GroupDetailResponse(GroupResponse):
"""Schema for detailed group response including users."""
users: list["UserBrief"] = []
class UserBrief(BaseModel):
"""Brief user info for embedding in group response."""
id: int
username: str
is_active: bool
class Config:
from_attributes = True
class PermissionInfo(BaseModel):
"""Schema for permission information."""
value: str
label: str
class PermissionCategory(BaseModel):
"""Schema for a category of permissions."""
name: str
permissions: list[PermissionInfo]
class PermissionsListResponse(BaseModel):
"""Schema for listing all permissions by category."""
categories: list[PermissionCategory]
all_permissions: list[str]
# Update forward references
GroupDetailResponse.model_rebuild()
+6
View File
@@ -67,6 +67,7 @@ async def test_engine():
archive,
external_link,
filament,
group,
kprofile_note,
maintenance,
notification,
@@ -122,6 +123,11 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
patch("backend.app.core.auth.async_session", test_async_session),
patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
):
# Seed default groups for tests that need them
from backend.app.core.database import seed_default_groups
await seed_default_groups()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
yield client
+330 -1
View File
@@ -205,7 +205,18 @@ class TestUsersAPI:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_users_requires_auth(self, async_client: AsyncClient):
"""Verify listing users requires authentication."""
"""Verify listing users requires authentication when auth is enabled."""
# First enable auth
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "authreqadmin",
"admin_password": "adminpassword123",
},
)
# Now try to list users without a token
response = await async_client.get("/api/v1/users/")
assert response.status_code == 401
@@ -360,3 +371,321 @@ class TestAuthDisableAPI:
# Verify auth is now disabled
status_response = await async_client.get("/api/v1/auth/status")
assert status_response.json()["auth_enabled"] is False
class TestGroupsAPI:
"""Integration tests for /api/v1/groups/ endpoints."""
@pytest.fixture
async def auth_token(self, async_client: AsyncClient):
"""Setup auth and return admin token."""
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "groupsadmin",
"admin_password": "adminpassword123",
},
)
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "groupsadmin", "password": "adminpassword123"},
)
return login_response.json()["access_token"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_groups(self, async_client: AsyncClient, auth_token: str):
"""Verify listing groups returns default groups."""
response = await async_client.get(
"/api/v1/groups/",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert response.status_code == 200
groups = response.json()
assert isinstance(groups, list)
# Should have default groups: Administrators, Operators, Viewers
group_names = [g["name"] for g in groups]
assert "Administrators" in group_names
assert "Operators" in group_names
assert "Viewers" in group_names
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_permissions(self, async_client: AsyncClient, auth_token: str):
"""Verify getting available permissions."""
response = await async_client.get(
"/api/v1/groups/permissions",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert response.status_code == 200
permissions = response.json()
assert isinstance(permissions, dict)
# Should have permission categories
assert "Printers" in permissions or len(permissions) > 0
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_group(self, async_client: AsyncClient, auth_token: str):
"""Verify creating a new group."""
response = await async_client.post(
"/api/v1/groups/",
headers={"Authorization": f"Bearer {auth_token}"},
json={
"name": "Custom Group",
"description": "A custom test group",
"permissions": ["printers:read", "archives:read"],
},
)
assert response.status_code == 201
group = response.json()
assert group["name"] == "Custom Group"
assert group["description"] == "A custom test group"
assert "printers:read" in group["permissions"]
assert group["is_system"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_group(self, async_client: AsyncClient, auth_token: str):
"""Verify updating a group."""
# Create a group first
create_response = await async_client.post(
"/api/v1/groups/",
headers={"Authorization": f"Bearer {auth_token}"},
json={
"name": "Update Test Group",
"permissions": ["printers:read"],
},
)
group_id = create_response.json()["id"]
# Update the group
response = await async_client.patch(
f"/api/v1/groups/{group_id}",
headers={"Authorization": f"Bearer {auth_token}"},
json={
"description": "Updated description",
"permissions": ["printers:read", "printers:control"],
},
)
assert response.status_code == 200
group = response.json()
assert group["description"] == "Updated description"
assert "printers:control" in group["permissions"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cannot_delete_system_group(self, async_client: AsyncClient, auth_token: str):
"""Verify system groups cannot be deleted."""
# Get the Administrators group
list_response = await async_client.get(
"/api/v1/groups/",
headers={"Authorization": f"Bearer {auth_token}"},
)
admin_group = next(g for g in list_response.json() if g["name"] == "Administrators")
# Try to delete it
response = await async_client.delete(
f"/api/v1/groups/{admin_group['id']}",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert response.status_code == 400
assert "system group" in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_custom_group(self, async_client: AsyncClient, auth_token: str):
"""Verify custom groups can be deleted."""
# Create a group
create_response = await async_client.post(
"/api/v1/groups/",
headers={"Authorization": f"Bearer {auth_token}"},
json={"name": "Delete Test Group"},
)
group_id = create_response.json()["id"]
# Delete it
response = await async_client.delete(
f"/api/v1/groups/{group_id}",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert response.status_code == 204
class TestUserGroupsAPI:
"""Integration tests for user-group assignments."""
@pytest.fixture
async def auth_token(self, async_client: AsyncClient):
"""Setup auth and return admin token."""
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "usergroupadmin",
"admin_password": "adminpassword123",
},
)
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "usergroupadmin", "password": "adminpassword123"},
)
return login_response.json()["access_token"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_user_with_groups(self, async_client: AsyncClient, auth_token: str):
"""Verify creating a user with group assignments."""
# Get Operators group ID
groups_response = await async_client.get(
"/api/v1/groups/",
headers={"Authorization": f"Bearer {auth_token}"},
)
operators_group = next(g for g in groups_response.json() if g["name"] == "Operators")
# Create user with group
response = await async_client.post(
"/api/v1/users/",
headers={"Authorization": f"Bearer {auth_token}"},
json={
"username": "groupuser",
"password": "password123",
"group_ids": [operators_group["id"]],
},
)
assert response.status_code == 201
user = response.json()
assert any(g["name"] == "Operators" for g in user["groups"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_user_to_group(self, async_client: AsyncClient, auth_token: str):
"""Verify adding a user to a group."""
# Create a user
user_response = await async_client.post(
"/api/v1/users/",
headers={"Authorization": f"Bearer {auth_token}"},
json={"username": "addtogroup", "password": "password123"},
)
user_id = user_response.json()["id"]
# Get Viewers group
groups_response = await async_client.get(
"/api/v1/groups/",
headers={"Authorization": f"Bearer {auth_token}"},
)
viewers_group = next(g for g in groups_response.json() if g["name"] == "Viewers")
# Add user to group
response = await async_client.post(
f"/api/v1/groups/{viewers_group['id']}/users/{user_id}",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert response.status_code == 204
# Verify user is in group
user_check = await async_client.get(
f"/api/v1/users/{user_id}",
headers={"Authorization": f"Bearer {auth_token}"},
)
assert any(g["name"] == "Viewers" for g in user_check.json()["groups"])
class TestChangePasswordAPI:
"""Integration tests for /api/v1/users/me/change-password endpoint."""
@pytest.fixture
async def user_token(self, async_client: AsyncClient):
"""Setup auth and return regular user token."""
# Enable auth with admin
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "pwchangeadmin",
"admin_password": "adminpassword123",
},
)
admin_login = await async_client.post(
"/api/v1/auth/login",
json={"username": "pwchangeadmin", "password": "adminpassword123"},
)
admin_token = admin_login.json()["access_token"]
# Create a regular user
await async_client.post(
"/api/v1/users/",
headers={"Authorization": f"Bearer {admin_token}"},
json={"username": "pwchangeuser", "password": "oldpassword123"},
)
# Login as regular user
user_login = await async_client.post(
"/api/v1/auth/login",
json={"username": "pwchangeuser", "password": "oldpassword123"},
)
return user_login.json()["access_token"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_change_password_success(self, async_client: AsyncClient, user_token: str):
"""Verify user can change their own password."""
response = await async_client.post(
"/api/v1/users/me/change-password",
headers={"Authorization": f"Bearer {user_token}"},
json={
"current_password": "oldpassword123",
"new_password": "newpassword456",
},
)
assert response.status_code == 200
assert "success" in response.json()["message"].lower()
# Verify can login with new password
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "pwchangeuser", "password": "newpassword456"},
)
assert login_response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_change_password_wrong_current(self, async_client: AsyncClient, user_token: str):
"""Verify changing password fails with wrong current password."""
response = await async_client.post(
"/api/v1/users/me/change-password",
headers={"Authorization": f"Bearer {user_token}"},
json={
"current_password": "wrongpassword",
"new_password": "newpassword456",
},
)
assert response.status_code == 400
assert "incorrect" in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_change_password_requires_auth(self, async_client: AsyncClient):
"""Verify changing password requires authentication."""
response = await async_client.post(
"/api/v1/users/me/change-password",
json={
"current_password": "oldpassword",
"new_password": "newpassword",
},
)
assert response.status_code == 401
+4 -4
View File
@@ -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);
});
});
});
+84
View File
@@ -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
View File
@@ -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/'),
+2
View File
@@ -9,6 +9,7 @@ export interface ContextMenuItem {
disabled?: boolean;
divider?: boolean;
submenu?: ContextMenuItem[];
title?: string;
}
interface ContextMenuProps {
@@ -163,6 +164,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
}
}}
disabled={item.disabled}
title={item.title}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
item.disabled
? 'text-bambu-gray cursor-not-allowed'
+27 -12
View File
@@ -19,10 +19,11 @@ import {
StickyNote,
} from 'lucide-react';
import { api } from '../api/client';
import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
import type { KProfile, KProfileCreate, KProfileDelete, Permission } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
interface KProfileCardProps {
profile: KProfile;
@@ -154,6 +155,7 @@ interface KProfileModalProps {
onClose: () => void;
onSave: () => void;
onSaveNote?: (settingId: string, note: string) => void; // Callback to save note
hasPermission: (permission: Permission) => boolean;
}
function KProfileModal({
@@ -167,6 +169,7 @@ function KProfileModal({
onClose,
onSave,
onSaveNote,
hasPermission,
}: KProfileModalProps) {
const { showToast } = useToast();
@@ -590,7 +593,8 @@ function KProfileModal({
type="button"
variant="secondary"
onClick={() => setShowDeleteConfirm(true)}
disabled={deleteMutation.isPending || isSyncing}
disabled={deleteMutation.isPending || isSyncing || !hasPermission('kprofiles:delete')}
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete K-profiles' : undefined}
className="text-red-500 hover:bg-red-500/10"
>
{deleteMutation.isPending ? (
@@ -611,7 +615,8 @@ function KProfileModal({
</Button>
<Button
type="submit"
disabled={saveMutation.isPending || isSyncing}
disabled={saveMutation.isPending || isSyncing || !hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create')}
title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? `You do not have permission to ${profile ? 'update' : 'create'} K-profiles` : undefined}
className="flex-1"
>
{saveMutation.isPending ? (
@@ -687,6 +692,7 @@ const STORAGE_KEYS = {
export function KProfilesView() {
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
// Load nozzle diameter from localStorage
const [nozzleDiameter, setNozzleDiameter] = useState(() => {
@@ -1139,12 +1145,17 @@ export function KProfilesView() {
<Button
variant="secondary"
onClick={() => refetchProfiles()}
disabled={isFetching}
disabled={isFetching || !hasPermission('kprofiles:read')}
title={!hasPermission('kprofiles:read') ? 'You do not have permission to refresh profiles' : undefined}
>
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => setShowAddModal(true)}>
<Button
onClick={() => setShowAddModal(true)}
disabled={!hasPermission('kprofiles:create')}
title={!hasPermission('kprofiles:create') ? 'You do not have permission to add profiles' : undefined}
>
<Plus className="w-4 h-4" />
Add Profile
</Button>
@@ -1205,8 +1216,8 @@ export function KProfilesView() {
<Button
variant="secondary"
onClick={handleExport}
disabled={!kprofiles?.profiles?.length}
title="Export profiles to JSON"
disabled={!kprofiles?.profiles?.length || !hasPermission('kprofiles:read')}
title={!hasPermission('kprofiles:read') ? 'You do not have permission to export profiles' : 'Export profiles to JSON'}
>
<Download className="w-4 h-4" />
Export
@@ -1214,7 +1225,8 @@ export function KProfilesView() {
<Button
variant="secondary"
onClick={handleImport}
title="Import profiles from JSON"
disabled={!hasPermission('kprofiles:create')}
title={!hasPermission('kprofiles:create') ? 'You do not have permission to import profiles' : 'Import profiles from JSON'}
>
<Upload className="w-4 h-4" />
Import
@@ -1233,9 +1245,9 @@ export function KProfilesView() {
<Button
variant="secondary"
onClick={handleBulkDelete}
disabled={selectedProfiles.size === 0}
disabled={selectedProfiles.size === 0 || !hasPermission('kprofiles:delete')}
className="text-red-500 hover:bg-red-500/10"
title={`Delete ${selectedProfiles.size} selected profiles`}
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : `Delete ${selectedProfiles.size} selected profiles`}
>
<Trash2 className="w-4 h-4" />
Delete ({selectedProfiles.size})
@@ -1255,8 +1267,8 @@ export function KProfilesView() {
<Button
variant="secondary"
onClick={() => setSelectionMode(true)}
disabled={!filteredProfiles.length}
title="Enter selection mode for bulk delete"
disabled={!filteredProfiles.length || !hasPermission('kprofiles:delete')}
title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : 'Enter selection mode for bulk delete'}
>
<CheckSquare className="w-4 h-4" />
Select
@@ -1384,6 +1396,7 @@ export function KProfilesView() {
initialNote={note}
initialNoteKey={key}
onSaveNote={handleSaveNote}
hasPermission={hasPermission}
onClose={() => {
console.log('[KProfiles] Edit modal onClose - refetching profiles...');
setEditingProfile(null);
@@ -1405,6 +1418,7 @@ export function KProfilesView() {
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onSaveNote={handleSaveNote}
hasPermission={hasPermission}
onClose={() => {
setShowAddModal(false);
refetchProfiles(); // Refetch after close
@@ -1424,6 +1438,7 @@ export function KProfilesView() {
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onSaveNote={handleSaveNote}
hasPermission={hasPermission}
// Pass profile data but without slot_id to create a new profile
profile={{
...copyingProfile,
+216 -38
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, type LucideIcon } from 'lucide-react';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -10,6 +10,9 @@ import { api, supportApi, pendingUploadsApi } from '../api/client';
import { getIconByName } from './IconPicker';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { Card, CardHeader, CardContent } from './Card';
import { Button } from './Button';
interface NavItem {
id: string;
@@ -69,7 +72,11 @@ export function Layout() {
const { mode, toggleMode } = useTheme();
const { t } = useTranslation();
const isMobile = useIsMobile();
const { user, authEnabled, logout } = useAuth();
const { user, authEnabled, logout, hasPermission } = useAuth();
const { showToast } = useToast();
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
const [changePasswordLoading, setChangePasswordLoading] = useState(false);
const [sidebarExpanded, setSidebarExpanded] = useState(() => {
const stored = localStorage.getItem('sidebarExpanded');
return stored !== 'false';
@@ -564,17 +571,26 @@ export function Layout() {
)}
</div>
)}
<NavLink
to="/system"
className={({ isActive }) =>
`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
}`
}
title={t('nav.system')}
>
<Info className="w-5 h-5" />
</NavLink>
{hasPermission('system:read') ? (
<NavLink
to="/system"
className={({ isActive }) =>
`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
}`
}
title={t('nav.system')}
>
<Info className="w-5 h-5" />
</NavLink>
) : (
<span
className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
title="You do not have permission to view system information"
>
<Info className="w-5 h-5" />
</span>
)}
<a
href="https://github.com/maziggy/bambuddy"
target="_blank"
@@ -599,13 +615,22 @@ export function Layout() {
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
{authEnabled && user && (
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.logout', { defaultValue: 'Logout' })}
>
<LogOut className="w-5 h-5" />
</button>
<>
<button
onClick={() => setShowChangePasswordModal(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title="Change Password"
>
<Key className="w-5 h-5" />
</button>
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.logout', { defaultValue: 'Logout' })}
>
<LogOut className="w-5 h-5" />
</button>
</>
)}
</div>
{/* Bottom row: version */}
@@ -650,17 +675,26 @@ export function Layout() {
)}
</div>
)}
<NavLink
to="/system"
className={({ isActive }) =>
`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
}`
}
title={t('nav.system')}
>
<Info className="w-5 h-5" />
</NavLink>
{hasPermission('system:read') ? (
<NavLink
to="/system"
className={({ isActive }) =>
`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
}`
}
title={t('nav.system')}
>
<Info className="w-5 h-5" />
</NavLink>
) : (
<span
className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
title="You do not have permission to view system information"
>
<Info className="w-5 h-5" />
</span>
)}
<a
href="https://github.com/maziggy/bambuddy"
target="_blank"
@@ -685,13 +719,22 @@ export function Layout() {
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
{authEnabled && user && (
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.logout', { defaultValue: 'Logout' })}
>
<LogOut className="w-5 h-5" />
</button>
<>
<button
onClick={() => setShowChangePasswordModal(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title="Change Password"
>
<Key className="w-5 h-5" />
</button>
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.logout', { defaultValue: 'Logout' })}
>
<LogOut className="w-5 h-5" />
</button>
</>
)}
</div>
)}
@@ -800,6 +843,141 @@ export function Layout() {
</div>
</div>
)}
{/* Change Password Modal */}
{showChangePasswordModal && (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={() => {
setShowChangePasswordModal(false);
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
}}
>
<Card
className="w-full max-w-md"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Key className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Change Password</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowChangePasswordModal(false);
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
}}
>
<X className="w-5 h-5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Current Password
</label>
<input
type="password"
value={changePasswordData.currentPassword}
onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter current password"
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
New Password
</label>
<input
type="password"
value={changePasswordData.newPassword}
onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
placeholder="Enter new password (min 6 characters)"
autoComplete="new-password"
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Confirm New Password
</label>
<input
type="password"
value={changePasswordData.confirmPassword}
onChange={(e) => setChangePasswordData({ ...changePasswordData, confirmPassword: e.target.value })}
className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${
changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword
? 'border-red-500'
: 'border-bambu-dark-tertiary'
}`}
placeholder="Confirm new password"
autoComplete="new-password"
minLength={6}
/>
{changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
<p className="text-red-400 text-xs mt-1">Passwords do not match</p>
)}
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => {
setShowChangePasswordModal(false);
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
}}
>
Cancel
</Button>
<Button
onClick={async () => {
if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
showToast('Passwords do not match', 'error');
return;
}
if (changePasswordData.newPassword.length < 6) {
showToast('Password must be at least 6 characters', 'error');
return;
}
setChangePasswordLoading(true);
try {
await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
showToast('Password changed successfully', 'success');
setShowChangePasswordModal(false);
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Failed to change password';
showToast(message, 'error');
} finally {
setChangePasswordLoading(false);
}
}}
disabled={changePasswordLoading || !changePasswordData.currentPassword || !changePasswordData.newPassword || changePasswordData.newPassword !== changePasswordData.confirmPassword || changePasswordData.newPassword.length < 6}
>
{changePasswordLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Changing...
</>
) : (
<>
<Key className="w-4 h-4" />
Change Password
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}
+40 -2
View File
@@ -1,16 +1,20 @@
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { api, getAuthToken, setAuthToken } from '../api/client';
import type { UserResponse } from '../api/client';
import type { Permission, UserResponse } from '../api/client';
interface AuthContextType {
user: UserResponse | null;
authEnabled: boolean;
requiresSetup: boolean;
loading: boolean;
isAdmin: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
refreshAuth: () => Promise<void>;
hasPermission: (permission: Permission) => boolean;
hasAnyPermission: (...permissions: Permission[]) => boolean;
hasAllPermissions: (...permissions: Permission[]) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@@ -122,6 +126,36 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await checkAuthStatus();
};
// Memoize permission set for efficient lookups
const permissionSet = useMemo(() => {
return new Set(user?.permissions ?? []);
}, [user?.permissions]);
// Computed admin status
const isAdmin = useMemo(() => {
if (!authEnabled) return true; // Auth disabled = admin access
return user?.is_admin ?? false;
}, [authEnabled, user?.is_admin]);
// Permission check functions
const hasPermission = useCallback((permission: Permission): boolean => {
if (!authEnabled) return true; // Auth disabled = allow all
if (isAdmin) return true; // Admins have all permissions
return permissionSet.has(permission);
}, [authEnabled, isAdmin, permissionSet]);
const hasAnyPermission = useCallback((...permissions: Permission[]): boolean => {
if (!authEnabled) return true;
if (isAdmin) return true;
return permissions.some(p => permissionSet.has(p));
}, [authEnabled, isAdmin, permissionSet]);
const hasAllPermissions = useCallback((...permissions: Permission[]): boolean => {
if (!authEnabled) return true;
if (isAdmin) return true;
return permissions.every(p => permissionSet.has(p));
}, [authEnabled, isAdmin, permissionSet]);
return (
<AuthContext.Provider
value={{
@@ -129,10 +163,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
authEnabled,
requiresSetup,
loading,
isAdmin,
login,
logout,
refreshUser,
refreshAuth,
hasPermission,
hasAnyPermission,
hasAllPermissions,
}}
>
{children}
+94 -12
View File
@@ -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>
+138 -46
View File
@@ -45,12 +45,14 @@ import type {
LibraryFolderUpdate,
AppSettings,
Archive,
Permission,
} from '../api/client';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { PrintModal } from '../components/PrintModal';
import { useToast } from '../contexts/ToastContext';
import { useIsMobile } from '../hooks/useIsMobile';
import { useAuth } from '../contexts/AuthContext';
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
type SortDirection = 'asc' | 'desc';
@@ -724,9 +726,10 @@ interface FolderTreeItemProps {
onRename: (folder: LibraryFolderTree) => void;
depth?: number;
wrapNames?: boolean;
hasPermission: (permission: Permission) => boolean;
}
function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false }: FolderTreeItemProps) {
function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink, onRename, depth = 0, wrapNames = false, hasPermission }: FolderTreeItemProps) {
const [expanded, setExpanded] = useState(true);
const [showActions, setShowActions] = useState(false);
const hasChildren = folder.children.length > 0;
@@ -799,22 +802,34 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
<div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
<div className="absolute right-0 top-full mt-1 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onRename(folder); setShowActions(false); }}
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:update')) { onRename(folder); setShowActions(false); } }}
disabled={!hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to rename folders' : undefined}
>
<Pencil className="w-3.5 h-3.5" />
Rename
</button>
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onLink(folder); setShowActions(false); }}
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:update')) { onLink(folder); setShowActions(false); } }}
disabled={!hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to link folders' : undefined}
>
<Link2 className="w-3.5 h-3.5" />
{isLinked ? 'Change Link...' : 'Link to...'}
</button>
<button
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onDelete(folder.id); setShowActions(false); }}
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:delete')) { onDelete(folder.id); setShowActions(false); } }}
disabled={!hasPermission('library:delete')}
title={!hasPermission('library:delete') ? 'You do not have permission to delete folders' : undefined}
>
<Trash2 className="w-3.5 h-3.5" />
Delete
@@ -838,6 +853,7 @@ function FolderTreeItem({ folder, selectedFolderId, onSelect, onDelete, onLink,
onRename={onRename}
depth={depth + 1}
wrapNames={wrapNames}
hasPermission={hasPermission}
/>
))}
</div>
@@ -865,9 +881,10 @@ interface FileCardProps {
onRename?: (file: LibraryFileListItem) => void;
onGenerateThumbnail?: (file: LibraryFileListItem) => void;
thumbnailVersion?: number;
hasPermission: (permission: Permission) => boolean;
}
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) {
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion, hasPermission }: FileCardProps) {
const [showActions, setShowActions] = useState(false);
return (
@@ -936,8 +953,12 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
<div className="absolute right-0 bottom-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[140px]">
{onPrint && isSlicedFilename(file.filename) && (
<button
className="w-full px-3 py-1.5 text-left text-sm text-bambu-green hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onPrint(file); setShowActions(false); }}
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('printers:control') ? 'text-bambu-green hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('printers:control')) { onPrint(file); setShowActions(false); } }}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? 'You do not have permission to print' : undefined}
>
<Printer className="w-3.5 h-3.5" />
Print
@@ -945,24 +966,36 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
)}
{onAddToQueue && isSlicedFilename(file.filename) && (
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onAddToQueue(file.id); setShowActions(false); }}
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('queue:create') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('queue:create')) { onAddToQueue(file.id); setShowActions(false); } }}
disabled={!hasPermission('queue:create')}
title={!hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined}
>
<Clock className="w-3.5 h-3.5" />
Add to Queue
</button>
)}
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onDownload(file.id); setShowActions(false); }}
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:read') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:read')) { onDownload(file.id); setShowActions(false); } }}
disabled={!hasPermission('library:read')}
title={!hasPermission('library:read') ? 'You do not have permission to download files' : undefined}
>
<Download className="w-3.5 h-3.5" />
Download
</button>
{onRename && (
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onRename(file); setShowActions(false); }}
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:update')) { onRename(file); setShowActions(false); } }}
disabled={!hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to rename files' : undefined}
>
<Pencil className="w-3.5 h-3.5" />
Rename
@@ -970,16 +1003,24 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
)}
{onGenerateThumbnail && file.file_type === 'stl' && (
<button
className="w-full px-3 py-1.5 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onGenerateThumbnail(file); setShowActions(false); }}
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:update')) { onGenerateThumbnail(file); setShowActions(false); } }}
disabled={!hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to generate thumbnails' : undefined}
>
<Image className="w-3.5 h-3.5" />
Generate Thumbnail
</button>
)}
<button
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onDelete(file.id); setShowActions(false); }}
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:delete')) { onDelete(file.id); setShowActions(false); } }}
disabled={!hasPermission('library:delete')}
title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined}
>
<Trash2 className="w-3.5 h-3.5" />
Delete
@@ -1004,6 +1045,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
export function FileManagerPage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [searchParams] = useSearchParams();
// Read folder ID from URL query parameter
@@ -1470,8 +1512,8 @@ export function FileManagerPage() {
<Button
variant="secondary"
onClick={() => batchThumbnailMutation.mutate()}
disabled={batchThumbnailMutation.isPending}
title="Generate thumbnails for STL files missing them"
disabled={batchThumbnailMutation.isPending || !hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to generate thumbnails' : 'Generate thumbnails for STL files missing them'}
>
{batchThumbnailMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
@@ -1480,11 +1522,20 @@ export function FileManagerPage() {
)}
Generate Thumbnails
</Button>
<Button variant="secondary" onClick={() => setShowNewFolderModal(true)}>
<Button
variant="secondary"
onClick={() => setShowNewFolderModal(true)}
disabled={!hasPermission('library:upload')}
title={!hasPermission('library:upload') ? 'You do not have permission to create folders' : undefined}
>
<FolderPlus className="w-4 h-4 mr-2" />
New Folder
</Button>
<Button onClick={() => setShowUploadModal(true)}>
<Button
onClick={() => setShowUploadModal(true)}
disabled={!hasPermission('library:upload')}
title={!hasPermission('library:upload') ? 'You do not have permission to upload files' : undefined}
>
<Upload className="w-4 h-4 mr-2" />
Upload
</Button>
@@ -1634,6 +1685,7 @@ export function FileManagerPage() {
onLink={setLinkFolder}
onRename={(f) => setRenameItem({ type: 'folder', id: f.id, name: f.name })}
wrapNames={wrapFolderNames}
hasPermission={hasPermission}
/>
))}
</div>
@@ -1752,6 +1804,8 @@ export function FileManagerPage() {
variant="primary"
size="sm"
onClick={() => setPrintMultiFile(selectedSlicedFiles[0])}
disabled={!hasPermission('printers:control')}
title={!hasPermission('printers:control') ? 'You do not have permission to print' : undefined}
>
<Play className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Print</span>
@@ -1762,7 +1816,8 @@ export function FileManagerPage() {
variant={selectedSlicedFiles.length === 1 ? 'secondary' : 'primary'}
size="sm"
onClick={() => addToQueueMutation.mutate(selectedSlicedFiles.map(f => f.id))}
disabled={addToQueueMutation.isPending}
disabled={addToQueueMutation.isPending || !hasPermission('queue:create')}
title={!hasPermission('queue:create') ? 'You do not have permission to add to queue' : undefined}
>
<Clock className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">{addToQueueMutation.isPending ? 'Adding...' : `Add to Queue${selectedSlicedFiles.length < selectedFiles.length ? ` (${selectedSlicedFiles.length})` : ''}`}</span>
@@ -1772,6 +1827,8 @@ export function FileManagerPage() {
variant="secondary"
size="sm"
onClick={() => setShowMoveModal(true)}
disabled={!hasPermission('library:update')}
title={!hasPermission('library:update') ? 'You do not have permission to move files' : undefined}
>
<MoveRight className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Move</span>
@@ -1786,6 +1843,8 @@ export function FileManagerPage() {
setDeleteConfirm({ type: 'bulk', id: 0, count: selectedFiles.length });
}
}}
disabled={!hasPermission('library:delete')}
title={!hasPermission('library:delete') ? 'You do not have permission to delete files' : undefined}
>
<Trash2 className="w-4 h-4 sm:mr-1" />
<span className="hidden sm:inline">Delete</span>
@@ -1825,7 +1884,11 @@ export function FileManagerPage() {
? 'Upload files or move files into this folder to get started.'
: 'Upload files to start organizing your print-related files.'}
</p>
<Button onClick={() => setShowUploadModal(true)}>
<Button
onClick={() => setShowUploadModal(true)}
disabled={!hasPermission('library:upload')}
title={!hasPermission('library:upload') ? 'You do not have permission to upload files' : undefined}
>
<Plus className="w-4 h-4 mr-2" />
Upload Files
</Button>
@@ -1860,6 +1923,7 @@ export function FileManagerPage() {
onRename={(f) => setRenameItem({ type: 'file', id: f.id, name: f.filename })}
onGenerateThumbnail={(f) => singleThumbnailMutation.mutate(f.id)}
thumbnailVersion={thumbnailVersions[file.id]}
hasPermission={hasPermission}
/>
))}
</div>
@@ -1946,50 +2010,78 @@ export function FileManagerPage() {
{isSlicedFilename(file.filename) && (
<>
<button
onClick={() => setPrintFile(file)}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
title="Print"
onClick={() => hasPermission('printers:control') && setPrintFile(file)}
className={`p-1.5 rounded transition-colors ${
hasPermission('printers:control')
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('printers:control') ? 'Print' : 'You do not have permission to print'}
disabled={!hasPermission('printers:control')}
>
<Printer className="w-4 h-4" />
</button>
<button
onClick={() => addToQueueMutation.mutate([file.id])}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
title="Add to Queue"
disabled={addToQueueMutation.isPending}
onClick={() => hasPermission('queue:create') && addToQueueMutation.mutate([file.id])}
className={`p-1.5 rounded transition-colors ${
hasPermission('queue:create')
? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('queue:create') ? 'Add to Queue' : 'You do not have permission to add to queue'}
disabled={addToQueueMutation.isPending || !hasPermission('queue:create')}
>
<Clock className="w-4 h-4" />
</button>
</>
)}
<button
onClick={() => handleDownload(file.id)}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
title="Download"
onClick={() => hasPermission('library:read') && handleDownload(file.id)}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:read')
? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:read') ? 'Download' : 'You do not have permission to download files'}
disabled={!hasPermission('library:read')}
>
<Download className="w-4 h-4" />
</button>
<button
onClick={() => setRenameItem({ type: 'file', id: file.id, name: file.filename })}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
title="Rename"
onClick={() => hasPermission('library:update') && setRenameItem({ type: 'file', id: file.id, name: file.filename })}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:update')
? 'hover:bg-bambu-dark text-bambu-gray hover:text-white'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:update') ? 'Rename' : 'You do not have permission to rename files'}
disabled={!hasPermission('library:update')}
>
<Pencil className="w-4 h-4" />
</button>
{file.file_type === 'stl' && (
<button
onClick={() => singleThumbnailMutation.mutate(file.id)}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green transition-colors"
title="Generate Thumbnail"
disabled={singleThumbnailMutation.isPending}
onClick={() => hasPermission('library:update') && singleThumbnailMutation.mutate(file.id)}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:update')
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:update') ? 'Generate Thumbnail' : 'You do not have permission to generate thumbnails'}
disabled={singleThumbnailMutation.isPending || !hasPermission('library:update')}
>
<Image className="w-4 h-4" />
</button>
)}
<button
onClick={() => setDeleteConfirm({ type: 'file', id: file.id })}
className="p-1.5 rounded hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
title="Delete"
onClick={() => hasPermission('library:delete') && setDeleteConfirm({ type: 'file', id: file.id })}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:delete')
? 'hover:bg-bambu-dark text-bambu-gray hover:text-red-400'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:delete') ? 'Delete' : 'You do not have permission to delete files'}
disabled={!hasPermission('library:delete')}
>
<Trash2 className="w-4 h-4" />
</button>
+496
View File
@@ -0,0 +1,496 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
X,
Plus,
Edit2,
Trash2,
Save,
Loader2,
Shield,
ArrowLeft,
Users,
Check,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { api } from '../api/client';
import type { Group, GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { Button } from '../components/Button';
import { Card, CardContent, CardHeader } from '../components/Card';
import { ConfirmModal } from '../components/ConfirmModal';
export function GroupsPage() {
const navigate = useNavigate();
const { hasPermission } = useAuth();
const { showToast } = useToast();
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [deleteGroupId, setDeleteGroupId] = useState<number | null>(null);
const [formData, setFormData] = useState<{
name: string;
description: string;
permissions: Permission[];
}>({
name: '',
description: '',
permissions: [],
});
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());
// Close modal on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && (showCreateModal || editingGroup)) {
setShowCreateModal(false);
setEditingGroup(null);
resetForm();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showCreateModal, editingGroup]);
const { data: groups = [], isLoading: groupsLoading } = useQuery({
queryKey: ['groups'],
queryFn: () => api.getGroups(),
enabled: hasPermission('groups:read'),
});
const { data: permissionsData } = useQuery({
queryKey: ['permissions'],
queryFn: () => api.getPermissions(),
enabled: hasPermission('groups:read'),
});
const createMutation = useMutation({
mutationFn: (data: GroupCreate) => api.createGroup(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups'] });
setShowCreateModal(false);
resetForm();
showToast('Group created successfully');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: GroupUpdate }) => api.updateGroup(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups'] });
setEditingGroup(null);
resetForm();
showToast('Group updated successfully');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.deleteGroup(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['groups'] });
showToast('Group deleted successfully');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const resetForm = () => {
setFormData({ name: '', description: '', permissions: [] });
setExpandedCategories(new Set());
};
const handleCreate = () => {
if (!formData.name.trim()) {
showToast('Please enter a group name', 'error');
return;
}
createMutation.mutate({
name: formData.name,
description: formData.description || undefined,
permissions: formData.permissions,
});
};
const handleUpdate = () => {
if (!editingGroup) return;
if (!formData.name.trim()) {
showToast('Please enter a group name', 'error');
return;
}
updateMutation.mutate({
id: editingGroup.id,
data: {
name: formData.name !== editingGroup.name ? formData.name : undefined,
description: formData.description,
permissions: formData.permissions,
},
});
};
const handleDelete = (id: number) => {
setDeleteGroupId(id);
};
const startEdit = (group: Group) => {
setEditingGroup(group);
setFormData({
name: group.name,
description: group.description || '',
permissions: group.permissions,
});
// Expand categories that have selected permissions
const cats = new Set<string>();
permissionsData?.categories.forEach((cat) => {
if (cat.permissions.some((p) => group.permissions.includes(p.value))) {
cats.add(cat.name);
}
});
setExpandedCategories(cats);
};
const toggleCategory = (categoryName: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(categoryName)) {
next.delete(categoryName);
} else {
next.add(categoryName);
}
return next;
});
};
const togglePermission = (permission: Permission) => {
setFormData((prev) => {
const permissions = prev.permissions.includes(permission)
? prev.permissions.filter((p) => p !== permission)
: [...prev.permissions, permission];
return { ...prev, permissions };
});
};
const toggleCategoryPermissions = (category: PermissionCategory, checked: boolean) => {
setFormData((prev) => {
const categoryPerms = category.permissions.map((p) => p.value);
const otherPerms = prev.permissions.filter((p) => !categoryPerms.includes(p));
const permissions = checked ? [...otherPerms, ...categoryPerms] : otherPerms;
return { ...prev, permissions };
});
};
const isCategoryFullySelected = (category: PermissionCategory) => {
return category.permissions.every((p) => formData.permissions.includes(p.value));
};
const isCategoryPartiallySelected = (category: PermissionCategory) => {
const selected = category.permissions.filter((p) => formData.permissions.includes(p.value));
return selected.length > 0 && selected.length < category.permissions.length;
};
// Permission check
if (!hasPermission('groups:read')) {
return (
<div className="p-6">
<Card>
<CardContent className="py-6">
<div className="flex items-center gap-3 text-red-400">
<Shield className="w-5 h-5" />
<p className="text-white">You do not have permission to access this page.</p>
</div>
</CardContent>
</Card>
</div>
);
}
const renderPermissionEditor = () => (
<div className="space-y-2 max-h-96 overflow-y-auto">
{permissionsData?.categories.map((category) => (
<div key={category.name} className="border border-bambu-dark-tertiary rounded-lg overflow-hidden">
<div
className="flex items-center justify-between px-4 py-2 bg-bambu-dark-secondary cursor-pointer hover:bg-bambu-dark-tertiary transition-colors"
onClick={() => toggleCategory(category.name)}
>
<div className="flex items-center gap-3">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleCategoryPermissions(category, !isCategoryFullySelected(category));
}}
className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${
isCategoryFullySelected(category)
? 'bg-bambu-green border-bambu-green'
: isCategoryPartiallySelected(category)
? 'bg-bambu-green/50 border-bambu-green'
: 'border-bambu-gray hover:border-white'
}`}
>
{(isCategoryFullySelected(category) || isCategoryPartiallySelected(category)) && (
<Check className="w-3 h-3 text-white" />
)}
</button>
<span className="text-white font-medium">{category.name}</span>
<span className="text-xs text-bambu-gray">
({category.permissions.filter((p) => formData.permissions.includes(p.value)).length}/
{category.permissions.length})
</span>
</div>
{expandedCategories.has(category.name) ? (
<ChevronDown className="w-4 h-4 text-bambu-gray" />
) : (
<ChevronRight className="w-4 h-4 text-bambu-gray" />
)}
</div>
{expandedCategories.has(category.name) && (
<div className="p-3 bg-bambu-dark space-y-2">
{category.permissions.map((perm) => (
<label
key={perm.value}
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-bambu-dark-secondary cursor-pointer"
>
<input
type="checkbox"
checked={formData.permissions.includes(perm.value)}
onChange={() => togglePermission(perm.value)}
className="w-4 h-4 rounded border-bambu-gray text-bambu-green focus:ring-bambu-green focus:ring-offset-0 bg-bambu-dark-secondary"
/>
<span className="text-sm text-bambu-gray">{perm.label}</span>
</label>
))}
</div>
)}
</div>
))}
</div>
);
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/settings?tab=users')}
className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
title="Back to Settings"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Shield className="w-6 h-6 text-bambu-green" />
Group Management
</h1>
<p className="text-sm text-bambu-gray mt-1">
Manage permission groups for access control
</p>
</div>
</div>
{hasPermission('groups:create') && (
<Button
onClick={() => {
setShowCreateModal(true);
resetForm();
}}
>
<Plus className="w-4 h-4" />
Create Group
</Button>
)}
</div>
{groupsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{groups.map((group) => (
<Card key={group.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield
className={`w-5 h-5 ${
group.name === 'Administrators'
? 'text-purple-400'
: group.name === 'Operators'
? 'text-blue-400'
: group.name === 'Viewers'
? 'text-green-400'
: 'text-bambu-gray'
}`}
/>
<h3 className="text-lg font-semibold text-white">{group.name}</h3>
{group.is_system && (
<span className="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
System
</span>
)}
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-bambu-gray mb-4">{group.description || 'No description'}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-bambu-gray">
<Users className="w-4 h-4" />
<span>{group.user_count} users</span>
</div>
<div className="text-xs text-bambu-gray">
{group.permissions.length} permissions
</div>
</div>
<div className="flex gap-2 mt-4 pt-4 border-t border-bambu-dark-tertiary">
{hasPermission('groups:update') && (
<Button size="sm" variant="ghost" onClick={() => startEdit(group)}>
<Edit2 className="w-4 h-4" />
Edit
</Button>
)}
{hasPermission('groups:delete') && !group.is_system && (
<Button size="sm" variant="ghost" onClick={() => handleDelete(group.id)}>
<Trash2 className="w-4 h-4" />
Delete
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Create/Edit Group Modal */}
{(showCreateModal || editingGroup) && (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={() => {
setShowCreateModal(false);
setEditingGroup(null);
resetForm();
}}
>
<Card
className="w-full max-w-2xl max-h-[90vh] overflow-y-auto"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">
{editingGroup ? 'Edit Group' : 'Create Group'}
</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowCreateModal(false);
setEditingGroup(null);
resetForm();
}}
>
<X className="w-5 h-5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-white mb-2">
Group Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
disabled={editingGroup?.is_system}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50"
placeholder="Enter group name"
/>
{editingGroup?.is_system && (
<p className="text-xs text-yellow-400 mt-1">System group names cannot be changed</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors resize-none"
placeholder="Enter description (optional)"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
Permissions ({formData.permissions.length} selected)
</label>
{renderPermissionEditor()}
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => {
setShowCreateModal(false);
setEditingGroup(null);
resetForm();
}}
>
Cancel
</Button>
<Button
onClick={editingGroup ? handleUpdate : handleCreate}
disabled={createMutation.isPending || updateMutation.isPending || !formData.name.trim()}
>
{(createMutation.isPending || updateMutation.isPending) ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{editingGroup ? 'Saving...' : 'Creating...'}
</>
) : (
<>
<Save className="w-4 h-4" />
{editingGroup ? 'Save Changes' : 'Create Group'}
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Delete Confirmation Modal */}
{deleteGroupId !== null && (
<ConfirmModal
title="Delete Group"
message="Are you sure you want to delete this group? Users in this group will lose these permissions."
confirmText="Delete Group"
variant="danger"
onConfirm={() => {
deleteMutation.mutate(deleteGroupId);
setDeleteGroupId(null);
}}
onCancel={() => setDeleteGroupId(null)}
/>
)}
</div>
);
}
+61
View File
@@ -4,6 +4,7 @@ import { useMutation } from '@tanstack/react-query';
import { useAuth } from '../contexts/AuthContext';
import { useToast } from '../contexts/ToastContext';
import { useTheme } from '../contexts/ThemeContext';
import { HelpCircle, X } from 'lucide-react';
export function LoginPage() {
const navigate = useNavigate();
@@ -12,6 +13,7 @@ export function LoginPage() {
const { mode } = useTheme();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showForgotPassword, setShowForgotPassword] = useState(false);
const loginMutation = useMutation({
mutationFn: () => login(username, password),
@@ -96,8 +98,67 @@ export function LoginPage() {
{loginMutation.isPending ? 'Logging in...' : 'Sign in'}
</button>
</div>
<div className="text-center">
<button
type="button"
onClick={() => setShowForgotPassword(true)}
className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
>
Forgot your password?
</button>
</div>
</form>
</div>
{/* Forgot Password Modal */}
{showForgotPassword && (
<div
className="fixed inset-0 bg-black flex items-center justify-center z-50 p-4"
onClick={() => setShowForgotPassword(false)}
>
<div
className="w-full max-w-md bg-bambu-card rounded-xl border border-bambu-dark-tertiary shadow-lg p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<HelpCircle className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">Forgot Password</h2>
</div>
<button
onClick={() => setShowForgotPassword(false)}
className="p-1 rounded-lg hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<p className="text-bambu-gray">
If you've forgotten your password, please contact your system administrator to reset it.
</p>
<div className="bg-bambu-dark rounded-lg p-4 space-y-2">
<p className="text-sm text-white font-medium">How to reset your password:</p>
<ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
<li>Contact your Bambuddy administrator</li>
<li>Ask them to reset your password in User Management</li>
<li>They can set a new temporary password for you</li>
<li>Log in with the new password and change it in Settings</li>
</ol>
</div>
<button
onClick={() => setShowForgotPassword(false)}
className="w-full py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
>
Got it
</button>
</div>
</div>
</div>
)}
</div>
);
}
+47 -16
View File
@@ -36,11 +36,12 @@ import {
ExternalLink,
} from 'lucide-react';
import { api } from '../api/client';
import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client';
import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType, Permission } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { Toggle } from '../components/Toggle';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
// Icon mapping for maintenance types
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -199,10 +200,12 @@ function MaintenanceCard({
item,
onPerform,
onToggle,
hasPermission,
}: {
item: MaintenanceStatus;
onPerform: (id: number) => void;
onToggle: (id: number, enabled: boolean) => void;
hasPermission: (permission: Permission) => boolean;
}) {
const Icon = getIcon(item.maintenance_type_icon);
const intervalType = item.interval_type || 'hours';
@@ -324,15 +327,19 @@ function MaintenanceCard({
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
<Toggle
checked={item.enabled}
onChange={(checked) => onToggle(item.id, checked)}
/>
<span title={!hasPermission('maintenance:update') ? 'You do not have permission to update maintenance items' : undefined}>
<Toggle
checked={item.enabled}
onChange={(checked) => onToggle(item.id, checked)}
disabled={!hasPermission('maintenance:update')}
/>
</span>
<Button
size="sm"
variant={item.is_due ? 'primary' : 'secondary'}
onClick={() => onPerform(item.id)}
disabled={!item.enabled}
disabled={!item.enabled || !hasPermission('maintenance:update')}
title={!hasPermission('maintenance:update') ? 'You do not have permission to perform maintenance' : undefined}
className="!px-3"
>
<RotateCcw className="w-3.5 h-3.5" />
@@ -350,11 +357,13 @@ function PrinterSection({
onPerform,
onToggle,
onSetHours,
hasPermission,
}: {
overview: PrinterMaintenanceOverview;
onPerform: (id: number) => void;
onToggle: (id: number, enabled: boolean) => void;
onSetHours: (printerId: number, hours: number) => void;
hasPermission: (permission: Permission) => boolean;
}) {
const [expanded, setExpanded] = useState(true);
const [editingHours, setEditingHours] = useState(false);
@@ -445,14 +454,16 @@ function PrinterSection({
) : (
<button
onClick={() => {
if (!hasPermission('maintenance:update')) return;
setHoursInput(Math.round(overview.total_print_hours).toString());
setEditingHours(true);
}}
className="group"
className={`group ${!hasPermission('maintenance:update') ? 'cursor-not-allowed opacity-60' : ''}`}
title={!hasPermission('maintenance:update') ? 'You do not have permission to edit print hours' : undefined}
>
<div className="text-sm font-medium text-white group-hover:text-bambu-green transition-colors flex items-center gap-1">
<div className={`text-sm font-medium text-white ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''} transition-colors flex items-center gap-1`}>
{Math.round(overview.total_print_hours)} hours
<Edit3 className="w-3 h-3 text-bambu-gray group-hover:text-bambu-green" />
<Edit3 className={`w-3 h-3 text-bambu-gray ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''}`} />
</div>
<div className="text-xs text-bambu-gray">Total print time</div>
</button>
@@ -496,6 +507,7 @@ function PrinterSection({
item={item}
onPerform={onPerform}
onToggle={onToggle}
hasPermission={hasPermission}
/>
))}
</div>
@@ -515,6 +527,7 @@ function SettingsSection({
onDeleteType,
onAssignType,
onRemoveItem,
hasPermission,
}: {
overview: PrinterMaintenanceOverview[] | undefined;
types: MaintenanceType[];
@@ -524,6 +537,7 @@ function SettingsSection({
onDeleteType: (id: number) => void;
onAssignType: (printerId: number, typeId: number) => void;
onRemoveItem: (itemId: number) => void;
hasPermission: (permission: Permission) => boolean;
}) {
const [editingInterval, setEditingInterval] = useState<number | null>(null);
const [intervalInput, setIntervalInput] = useState('');
@@ -654,7 +668,11 @@ function SettingsSection({
<h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
<p className="text-sm text-bambu-gray mt-1">System types and your custom maintenance tasks</p>
</div>
<Button onClick={() => setShowAddType(!showAddType)}>
<Button
onClick={() => setShowAddType(!showAddType)}
disabled={!hasPermission('maintenance:create')}
title={!hasPermission('maintenance:create') ? 'You do not have permission to create maintenance types' : undefined}
>
<Plus className="w-4 h-4" />
Add Custom Type
</Button>
@@ -914,7 +932,9 @@ function SettingsSection({
</button>
<button
onClick={() => startEditType(type)}
className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors"
disabled={!hasPermission('maintenance:update')}
title={!hasPermission('maintenance:update') ? 'You do not have permission to edit maintenance types' : undefined}
className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors ${!hasPermission('maintenance:update') ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<Edit3 className="w-4 h-4" />
</button>
@@ -924,7 +944,9 @@ function SettingsSection({
onDeleteType(type.id);
}
}}
className="p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors"
disabled={!hasPermission('maintenance:delete')}
title={!hasPermission('maintenance:delete') ? 'You do not have permission to delete maintenance types' : undefined}
className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${!hasPermission('maintenance:delete') ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<Trash2 className="w-4 h-4" />
</button>
@@ -946,8 +968,9 @@ function SettingsSection({
{p.printerName}
<button
onClick={() => p.itemId && onRemoveItem(p.itemId)}
className="hover:text-red-400 ml-1"
title="Remove from this printer"
disabled={!hasPermission('maintenance:delete')}
title={!hasPermission('maintenance:delete') ? 'You do not have permission to remove printer assignments' : 'Remove from this printer'}
className={`ml-1 ${hasPermission('maintenance:delete') ? 'hover:text-red-400' : 'opacity-50 cursor-not-allowed'}`}
>
×
</button>
@@ -962,7 +985,9 @@ function SettingsSection({
<button
key={p.id}
onClick={() => onAssignType(p.id, type.id)}
className="px-2 py-1 bg-bambu-dark hover:bg-bambu-green/20 rounded text-xs text-bambu-gray hover:text-bambu-green transition-colors"
disabled={!hasPermission('maintenance:create')}
title={!hasPermission('maintenance:create') ? 'You do not have permission to assign printers' : undefined}
className={`px-2 py-1 bg-bambu-dark rounded text-xs transition-colors ${hasPermission('maintenance:create') ? 'hover:bg-bambu-green/20 text-bambu-gray hover:text-bambu-green' : 'opacity-50 cursor-not-allowed text-bambu-gray'}`}
>
+ {p.name}
</button>
@@ -1034,11 +1059,14 @@ function SettingsSection({
) : (
<button
onClick={() => {
if (!hasPermission('maintenance:update')) return;
setEditingInterval(item.id);
setIntervalInput(item.interval_hours.toString());
setIntervalTypeInput(intervalType);
}}
className="px-2 py-1 bg-bambu-dark-tertiary hover:bg-bambu-dark-secondary border border-bambu-dark-tertiary hover:border-bambu-green rounded text-xs font-medium text-white transition-colors flex items-center gap-1"
disabled={!hasPermission('maintenance:update')}
title={!hasPermission('maintenance:update') ? 'You do not have permission to edit intervals' : undefined}
className={`px-2 py-1 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs font-medium text-white transition-colors flex items-center gap-1 ${hasPermission('maintenance:update') ? 'hover:bg-bambu-dark-secondary hover:border-bambu-green' : 'opacity-50 cursor-not-allowed'}`}
>
{intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
{formatIntervalLabel(item.interval_hours, intervalType)}
@@ -1076,6 +1104,7 @@ type TabType = 'status' | 'settings';
export function MaintenancePage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [activeTab, setActiveTab] = useState<TabType>('status');
const { data: overview, isLoading } = useQuery({
@@ -1259,6 +1288,7 @@ export function MaintenancePage() {
onPerform={handlePerform}
onToggle={handleToggle}
onSetHours={handleSetHours}
hasPermission={hasPermission}
/>
))
) : (
@@ -1293,6 +1323,7 @@ export function MaintenancePage() {
onDeleteType={(id) => deleteTypeMutation.mutate(id)}
onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
hasPermission={hasPermission}
/>
)}
</div>
+100 -49
View File
@@ -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>
+46 -8
View File
@@ -42,10 +42,11 @@ import {
} from 'lucide-react';
import { api } from '../api/client';
import { parseUTCDate } from '../utils/date';
import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition } from '../api/client';
import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { KProfilesView } from '../components/KProfilesView';
type ProfileTab = 'cloud' | 'kprofiles';
@@ -506,12 +507,14 @@ function PresetDetailModal({
onDeleted,
onDuplicate,
onEdit,
hasPermission,
}: {
setting: SlicerSetting;
onClose: () => void;
onDeleted: () => void;
onDuplicate: () => void;
onEdit: () => void;
hasPermission: (permission: Permission) => boolean;
}) {
const { showToast } = useToast();
const queryClient = useQueryClient();
@@ -599,17 +602,32 @@ function PresetDetailModal({
<div className="flex-shrink-0 p-4 border-t border-bambu-dark-tertiary">
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose} className="flex-1">Close</Button>
<Button variant="secondary" onClick={onDuplicate}>
<Button
variant="secondary"
onClick={onDuplicate}
disabled={!hasPermission('cloud:auth')}
title={!hasPermission('cloud:auth') ? 'You do not have permission to duplicate presets' : undefined}
>
<Copy className="w-4 h-4" />
Duplicate
</Button>
{isEditable && (
<>
<Button variant="secondary" onClick={onEdit} disabled={isLoading || !detail}>
<Button
variant="secondary"
onClick={onEdit}
disabled={isLoading || !detail || !hasPermission('cloud:auth')}
title={!hasPermission('cloud:auth') ? 'You do not have permission to edit presets' : undefined}
>
<Pencil className="w-4 h-4" />
Edit
</Button>
<Button variant="danger" onClick={() => setShowDeleteConfirm(true)}>
<Button
variant="danger"
onClick={() => setShowDeleteConfirm(true)}
disabled={!hasPermission('cloud:auth')}
title={!hasPermission('cloud:auth') ? 'You do not have permission to delete presets' : undefined}
>
<Trash2 className="w-4 h-4" />
</Button>
</>
@@ -2206,12 +2224,14 @@ function CloudProfilesView({
onRefresh,
isRefreshing,
printers,
hasPermission,
}: {
settings: SlicerSettingsResponse;
lastSyncTime?: Date;
onRefresh: () => void;
isRefreshing: boolean;
printers: Printer[];
hasPermission: (permission: Permission) => boolean;
}) {
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<PresetType>('all');
@@ -2424,15 +2444,29 @@ function CloudProfilesView({
<GitCompare className="w-4 h-4" />
{compareMode ? 'Cancel' : 'Compare'}
</Button>
<Button variant="secondary" onClick={() => setShowTemplatesModal(true)}>
<Button
variant="secondary"
onClick={() => setShowTemplatesModal(true)}
disabled={!hasPermission('cloud:auth')}
title={!hasPermission('cloud:auth') ? 'You do not have permission to manage templates' : undefined}
>
<Sparkles className="w-4 h-4" />
Templates
</Button>
<Button variant="secondary" onClick={onRefresh} disabled={isRefreshing}>
<Button
variant="secondary"
onClick={onRefresh}
disabled={isRefreshing || !hasPermission('cloud:auth')}
title={!hasPermission('cloud:auth') ? 'You do not have permission to refresh profiles' : undefined}
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => setShowCreateModal(true)}>
<Button
onClick={() => setShowCreateModal(true)}
disabled={!hasPermission('cloud:auth')}
title={!hasPermission('cloud:auth') ? 'You do not have permission to create presets' : undefined}
>
<Plus className="w-4 h-4" />
New Preset
</Button>
@@ -2704,6 +2738,7 @@ function CloudProfilesView({
onDeleted={() => setSelectedSetting(null)}
onDuplicate={() => handleDuplicate(selectedSetting)}
onEdit={() => handleEdit(selectedSetting)}
hasPermission={hasPermission}
/>
)}
@@ -2748,6 +2783,7 @@ function CloudProfilesView({
export function ProfilesPage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [activeTab, setActiveTab] = useState<ProfileTab>('cloud');
const [lastSyncTime, setLastSyncTime] = useState<Date>();
@@ -2846,7 +2882,8 @@ export function ProfilesPage() {
variant="secondary"
size="sm"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
disabled={logoutMutation.isPending || !hasPermission('cloud:auth')}
title={!hasPermission('cloud:auth') ? 'You do not have permission to logout' : undefined}
>
<LogOut className="w-4 h-4" />
Logout
@@ -2867,6 +2904,7 @@ export function ProfilesPage() {
onRefresh={() => refetchSettings()}
isRefreshing={settingsLoading}
printers={printers}
hasPermission={hasPermission}
/>
) : (
<div className="text-center py-16">
+51 -14
View File
@@ -36,6 +36,7 @@ import type { Archive, ProjectUpdate, BOMItem, BOMItemCreate, BOMItemUpdate } fr
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
import { RichTextEditor } from '../components/RichTextEditor';
import { ConfirmModal } from '../components/ConfirmModal';
@@ -198,6 +199,7 @@ export function ProjectDetailPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [showEditModal, setShowEditModal] = useState(false);
const [editingNotes, setEditingNotes] = useState(false);
const [notesContent, setNotesContent] = useState('');
@@ -494,11 +496,20 @@ export function ProjectDetailPage() {
<StatusBadge status={project.status} />
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleExportProject} title="Export project">
<Button
variant="secondary"
onClick={handleExportProject}
disabled={!hasPermission('projects:read')}
title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export project'}
>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button onClick={() => setShowEditModal(true)}>
<Button
onClick={() => setShowEditModal(true)}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
>
<Edit3 className="w-4 h-4 mr-2" />
Edit
</Button>
@@ -761,7 +772,13 @@ export function ProjectDetailPage() {
Notes
</h2>
{!editingNotes ? (
<Button variant="secondary" size="sm" onClick={handleStartEditNotes}>
<Button
variant="secondary"
size="sm"
onClick={handleStartEditNotes}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? 'You do not have permission to edit notes' : undefined}
>
<Edit3 className="w-4 h-4 mr-1" />
Edit
</Button>
@@ -886,7 +903,13 @@ export function ProjectDetailPage() {
</button>
)}
{!showBomForm && (
<Button variant="secondary" size="sm" onClick={() => setShowBomForm(true)}>
<Button
variant="secondary"
size="sm"
onClick={() => setShowBomForm(true)}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? 'You do not have permission to add parts' : undefined}
>
<Plus className="w-4 h-4 mr-1" />
Add Part
</Button>
@@ -1031,12 +1054,15 @@ export function ProjectDetailPage() {
// Display mode
<div className="flex items-start gap-3">
<button
onClick={() => handleToggleAcquired(item)}
disabled={updateBomMutation.isPending}
onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
title={!hasPermission('projects:update') ? 'You do not have permission to update parts' : undefined}
className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
item.is_complete
? 'bg-status-ok border-status-ok text-white'
: 'border-bambu-gray hover:border-bambu-green'
: hasPermission('projects:update')
? 'border-bambu-gray hover:border-bambu-green'
: 'border-bambu-gray/50 cursor-not-allowed'
}`}
>
{item.is_complete && <CheckCircle className="w-3 h-3" />}
@@ -1058,16 +1084,26 @@ export function ProjectDetailPage() {
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleEditBomItem(item)}
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors flex-shrink-0"
title="Edit"
onClick={() => hasPermission('projects:update') && handleEditBomItem(item)}
disabled={!hasPermission('projects:update')}
className={`p-1 rounded transition-colors flex-shrink-0 ${
hasPermission('projects:update')
? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={!hasPermission('projects:update') ? 'You do not have permission to edit parts' : 'Edit'}
>
<Pencil className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteBomItem(item.id, item.name)}
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
title="Delete"
onClick={() => hasPermission('projects:update') && handleDeleteBomItem(item.id, item.name)}
disabled={!hasPermission('projects:update')}
className={`p-1 rounded transition-colors flex-shrink-0 ${
hasPermission('projects:update')
? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={!hasPermission('projects:update') ? 'You do not have permission to delete parts' : 'Delete'}
>
<Trash2 className="w-4 h-4" />
</button>
@@ -1178,7 +1214,8 @@ export function ProjectDetailPage() {
variant="secondary"
size="sm"
onClick={() => createTemplateMutation.mutate()}
disabled={createTemplateMutation.isPending}
disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
title={!hasPermission('projects:create') ? 'You do not have permission to create templates' : undefined}
>
{createTemplateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
+41 -10
View File
@@ -20,10 +20,11 @@ import {
Upload,
} from 'lucide-react';
import { api } from '../api/client';
import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport } from '../api/client';
import type { ProjectListItem, ProjectCreate, ProjectUpdate, ProjectImport, Permission } from '../api/client';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
const PROJECT_COLORS = [
'#ef4444', // red
@@ -244,9 +245,10 @@ interface ProjectCardProps {
onClick: () => void;
onEdit: () => void;
onDelete: () => void;
hasPermission: (permission: Permission) => boolean;
}
function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: ProjectCardProps) {
// Plates progress: archive_count / target_count
const platesProgressPercent = project.target_count
? Math.round((project.archive_count / project.target_count) * 100)
@@ -389,15 +391,23 @@ function ProjectCard({ project, onClick, onEdit, onDelete }: ProjectCardProps) {
<div className="fixed inset-0 z-10" onClick={() => setShowActions(false)} />
<div className="absolute right-0 top-8 z-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 min-w-[120px]">
<button
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onEdit(); setShowActions(false); }}
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
hasPermission('projects:update') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('projects:update')) { onEdit(); setShowActions(false); } }}
disabled={!hasPermission('projects:update')}
title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
>
<Edit3 className="w-4 h-4" />
Edit
</button>
<button
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-bambu-dark flex items-center gap-2"
onClick={() => { onDelete(); setShowActions(false); }}
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
hasPermission('projects:delete') ? 'text-red-400 hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('projects:delete')) { onDelete(); setShowActions(false); } }}
disabled={!hasPermission('projects:delete')}
title={!hasPermission('projects:delete') ? 'You do not have permission to delete projects' : undefined}
>
<Trash2 className="w-4 h-4" />
Delete
@@ -565,6 +575,7 @@ export function ProjectsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { showToast } = useToast();
const { hasPermission } = useAuth();
const [showModal, setShowModal] = useState(false);
const [editingProject, setEditingProject] = useState<ProjectListItem | undefined>();
const [statusFilter, setStatusFilter] = useState<string>('active');
@@ -763,15 +774,30 @@ export function ProjectsPage() {
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={handleImportClick} title="Import project">
<Button
variant="secondary"
onClick={handleImportClick}
disabled={!hasPermission('projects:create')}
title={!hasPermission('projects:create') ? 'You do not have permission to import projects' : 'Import project'}
>
<Upload className="w-4 h-4 mr-2" />
Import
</Button>
<Button variant="secondary" onClick={handleExportAll} title="Export all projects">
<Button
variant="secondary"
onClick={handleExportAll}
disabled={!hasPermission('projects:read')}
title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export all projects'}
>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button onClick={() => setShowModal(true)} className="sm:w-auto w-full">
<Button
onClick={() => setShowModal(true)}
className="sm:w-auto w-full"
disabled={!hasPermission('projects:create')}
title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
>
<Plus className="w-4 h-4 mr-2" />
New Project
</Button>
@@ -831,7 +857,11 @@ export function ProjectsPage() {
}
</p>
{statusFilter === 'all' && (
<Button onClick={() => setShowModal(true)}>
<Button
onClick={() => setShowModal(true)}
disabled={!hasPermission('projects:create')}
title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
>
<Plus className="w-4 h-4 mr-2" />
Create Your First Project
</Button>
@@ -846,6 +876,7 @@ export function ProjectsPage() {
onClick={() => handleClick(project)}
onEdit={() => handleEdit(project)}
onDelete={() => handleDeleteClick(project.id)}
hasPermission={hasPermission}
/>
))}
</div>
+28 -9
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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>