Added Roles and Permissions. Some minor UI fixes

This commit is contained in:
2026-02-18 13:12:55 +02:00
parent f54cdd525d
commit dbd15c00f8
31 changed files with 1825 additions and 331 deletions

View File

@@ -4,6 +4,7 @@ from jose import JWTError
from auth.utils import decode_access_token
from auth.models import TokenPayload, Role
from shared.exceptions import AuthenticationError, AuthorizationError
from shared.firebase import get_db
security = HTTPBearer()
@@ -28,7 +29,7 @@ def require_roles(*allowed_roles: Role):
async def role_checker(
current_user: TokenPayload = Depends(get_current_user),
) -> TokenPayload:
if current_user.role == Role.superadmin:
if current_user.role == Role.sysadmin:
return current_user
if current_user.role not in [r.value for r in allowed_roles]:
raise AuthorizationError()
@@ -36,12 +37,63 @@ def require_roles(*allowed_roles: Role):
return role_checker
async def _get_user_permissions(user: TokenPayload) -> dict:
"""Fetch permissions from Firestore for the given user."""
if user.role in (Role.sysadmin, Role.admin):
return None # Full access
db = get_db()
if not db:
raise AuthorizationError()
doc = db.collection("admin_users").document(user.sub).get()
if not doc.exists:
raise AuthorizationError()
data = doc.to_dict()
return data.get("permissions")
def require_permission(section: str, action: str):
"""Check granular permission for a section and action.
section: 'melodies', 'devices', 'app_users', 'equipment', 'mqtt'
action: 'view', 'add', 'edit', 'delete' (or ignored for mqtt)
"""
async def permission_checker(
current_user: TokenPayload = Depends(get_current_user),
) -> TokenPayload:
# sysadmin and admin have full access
if current_user.role in (Role.sysadmin, Role.admin):
return current_user
permissions = await _get_user_permissions(current_user)
if not permissions:
raise AuthorizationError()
if section == "mqtt":
if not permissions.get("mqtt", False):
raise AuthorizationError()
return current_user
section_perms = permissions.get(section)
if not section_perms:
raise AuthorizationError()
if isinstance(section_perms, dict):
if not section_perms.get(action, False):
raise AuthorizationError()
else:
raise AuthorizationError()
return current_user
return permission_checker
# Pre-built convenience dependencies
require_superadmin = require_roles(Role.superadmin)
require_melody_access = require_roles(Role.superadmin, Role.melody_editor)
require_device_access = require_roles(Role.superadmin, Role.device_manager)
require_user_access = require_roles(Role.superadmin, Role.user_manager)
require_viewer = require_roles(
Role.superadmin, Role.melody_editor, Role.device_manager,
Role.user_manager, Role.viewer,
require_sysadmin = require_roles(Role.sysadmin)
require_admin_or_above = require_roles(Role.sysadmin, Role.admin)
# Staff management: only sysadmin and admin
require_staff_management = require_roles(Role.sysadmin, Role.admin)
# Viewer-level: any authenticated user (actual permission check per-action)
require_any_authenticated = require_roles(
Role.sysadmin, Role.admin, Role.editor, Role.user,
)

View File

@@ -4,11 +4,49 @@ from enum import Enum
class Role(str, Enum):
superadmin = "superadmin"
melody_editor = "melody_editor"
device_manager = "device_manager"
user_manager = "user_manager"
viewer = "viewer"
sysadmin = "sysadmin"
admin = "admin"
editor = "editor"
user = "user"
class SectionPermissions(BaseModel):
view: bool = False
add: bool = False
edit: bool = False
delete: bool = False
class StaffPermissions(BaseModel):
melodies: SectionPermissions = SectionPermissions()
devices: SectionPermissions = SectionPermissions()
app_users: SectionPermissions = SectionPermissions()
equipment: SectionPermissions = SectionPermissions()
mqtt: bool = False
# Default permissions per role
def default_permissions_for_role(role: str) -> Optional[dict]:
if role in ("sysadmin", "admin"):
return None # Full access, permissions field not used
full = {"view": True, "add": True, "edit": True, "delete": True}
view_only = {"view": True, "add": False, "edit": False, "delete": False}
if role == "editor":
return {
"melodies": full,
"devices": full,
"app_users": full,
"equipment": full,
"mqtt": True,
}
# user role - view only
return {
"melodies": view_only,
"devices": view_only,
"app_users": view_only,
"equipment": view_only,
"mqtt": False,
}
class AdminUserInDB(BaseModel):
@@ -18,6 +56,7 @@ class AdminUserInDB(BaseModel):
name: str
role: Role
is_active: bool = True
permissions: Optional[StaffPermissions] = None
class LoginRequest(BaseModel):
@@ -30,6 +69,7 @@ class TokenResponse(BaseModel):
token_type: str = "bearer"
role: str
name: str
permissions: Optional[dict] = None
class TokenPayload(BaseModel):

View File

@@ -28,15 +28,32 @@ async def login(body: LoginRequest):
if not verify_password(body.password, user_data["hashed_password"]):
raise AuthenticationError("Invalid email or password")
role = user_data["role"]
# Map legacy roles to new roles
role_mapping = {
"superadmin": "sysadmin",
"melody_editor": "editor",
"device_manager": "editor",
"user_manager": "editor",
"viewer": "user",
}
role = role_mapping.get(role, role)
token = create_access_token({
"sub": doc.id,
"email": user_data["email"],
"role": user_data["role"],
"role": role,
"name": user_data["name"],
})
# Get permissions for editor/user roles
permissions = None
if role in ("editor", "user"):
permissions = user_data.get("permissions")
return TokenResponse(
access_token=token,
role=user_data["role"],
role=role,
name=user_data["name"],
permissions=permissions,
)

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, Query
from typing import Optional
from auth.models import TokenPayload
from auth.dependencies import require_device_access, require_viewer
from auth.dependencies import require_permission
from devices.models import (
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
DeviceUsersResponse, DeviceUserInfo,
@@ -16,7 +16,7 @@ async def list_devices(
search: Optional[str] = Query(None),
online: Optional[bool] = Query(None),
tier: Optional[str] = Query(None),
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("devices", "view")),
):
devices = service.list_devices(
search=search,
@@ -29,7 +29,7 @@ async def list_devices(
@router.get("/{device_id}", response_model=DeviceInDB)
async def get_device(
device_id: str,
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("devices", "view")),
):
return service.get_device(device_id)
@@ -37,7 +37,7 @@ async def get_device(
@router.get("/{device_id}/users", response_model=DeviceUsersResponse)
async def get_device_users(
device_id: str,
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("devices", "view")),
):
users_data = service.get_device_users(device_id)
users = [DeviceUserInfo(**u) for u in users_data]
@@ -47,7 +47,7 @@ async def get_device_users(
@router.post("", response_model=DeviceInDB, status_code=201)
async def create_device(
body: DeviceCreate,
_user: TokenPayload = Depends(require_device_access),
_user: TokenPayload = Depends(require_permission("devices", "add")),
):
return service.create_device(body)
@@ -56,7 +56,7 @@ async def create_device(
async def update_device(
device_id: str,
body: DeviceUpdate,
_user: TokenPayload = Depends(require_device_access),
_user: TokenPayload = Depends(require_permission("devices", "edit")),
):
return service.update_device(device_id, body)
@@ -64,6 +64,6 @@ async def update_device(
@router.delete("/{device_id}", status_code=204)
async def delete_device(
device_id: str,
_user: TokenPayload = Depends(require_device_access),
_user: TokenPayload = Depends(require_permission("devices", "delete")),
):
service.delete_device(device_id)

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, Query
from typing import Optional
from auth.models import TokenPayload
from auth.dependencies import require_device_access, require_viewer
from auth.dependencies import require_permission
from equipment.models import (
NoteCreate, NoteUpdate, NoteInDB, NoteListResponse,
)
@@ -16,7 +16,7 @@ async def list_notes(
category: Optional[str] = Query(None),
device_id: Optional[str] = Query(None),
user_id: Optional[str] = Query(None),
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("equipment", "view")),
):
notes = service.list_notes(
search=search, category=category,
@@ -28,7 +28,7 @@ async def list_notes(
@router.get("/{note_id}", response_model=NoteInDB)
async def get_note(
note_id: str,
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("equipment", "view")),
):
return service.get_note(note_id)
@@ -36,7 +36,7 @@ async def get_note(
@router.post("", response_model=NoteInDB, status_code=201)
async def create_note(
body: NoteCreate,
_user: TokenPayload = Depends(require_device_access),
_user: TokenPayload = Depends(require_permission("equipment", "add")),
):
return service.create_note(body, created_by=_user.name or _user.email)
@@ -45,7 +45,7 @@ async def create_note(
async def update_note(
note_id: str,
body: NoteUpdate,
_user: TokenPayload = Depends(require_device_access),
_user: TokenPayload = Depends(require_permission("equipment", "edit")),
):
return service.update_note(note_id, body)
@@ -53,6 +53,6 @@ async def update_note(
@router.delete("/{note_id}", status_code=204)
async def delete_note(
note_id: str,
_user: TokenPayload = Depends(require_device_access),
_user: TokenPayload = Depends(require_permission("equipment", "delete")),
):
service.delete_note(note_id)

View File

@@ -10,6 +10,7 @@ from settings.router import router as settings_router
from users.router import router as users_router
from mqtt.router import router as mqtt_router
from equipment.router import router as equipment_router
from staff.router import router as staff_router
from mqtt.client import mqtt_manager
from mqtt import database as mqtt_db
@@ -35,6 +36,7 @@ app.include_router(settings_router)
app.include_router(users_router)
app.include_router(mqtt_router)
app.include_router(equipment_router)
app.include_router(staff_router)
@app.on_event("startup")

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException
from typing import Optional
from auth.models import TokenPayload
from auth.dependencies import require_melody_access, require_viewer
from auth.dependencies import require_permission
from melodies.models import (
MelodyCreate, MelodyUpdate, MelodyInDB, MelodyListResponse, MelodyInfo,
)
@@ -16,7 +16,7 @@ async def list_melodies(
type: Optional[str] = Query(None),
tone: Optional[str] = Query(None),
total_notes: Optional[int] = Query(None),
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("melodies", "view")),
):
melodies = service.list_melodies(
search=search,
@@ -30,7 +30,7 @@ async def list_melodies(
@router.get("/{melody_id}", response_model=MelodyInDB)
async def get_melody(
melody_id: str,
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("melodies", "view")),
):
return service.get_melody(melody_id)
@@ -38,7 +38,7 @@ async def get_melody(
@router.post("", response_model=MelodyInDB, status_code=201)
async def create_melody(
body: MelodyCreate,
_user: TokenPayload = Depends(require_melody_access),
_user: TokenPayload = Depends(require_permission("melodies", "add")),
):
return service.create_melody(body)
@@ -47,7 +47,7 @@ async def create_melody(
async def update_melody(
melody_id: str,
body: MelodyUpdate,
_user: TokenPayload = Depends(require_melody_access),
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
return service.update_melody(melody_id, body)
@@ -55,7 +55,7 @@ async def update_melody(
@router.delete("/{melody_id}", status_code=204)
async def delete_melody(
melody_id: str,
_user: TokenPayload = Depends(require_melody_access),
_user: TokenPayload = Depends(require_permission("melodies", "delete")),
):
service.delete_melody(melody_id)
@@ -65,7 +65,7 @@ async def upload_file(
melody_id: str,
file_type: str,
file: UploadFile = File(...),
_user: TokenPayload = Depends(require_melody_access),
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
"""Upload a binary or preview file. file_type must be 'binary' or 'preview'."""
if file_type not in ("binary", "preview"):
@@ -100,7 +100,7 @@ async def upload_file(
async def delete_file(
melody_id: str,
file_type: str,
_user: TokenPayload = Depends(require_melody_access),
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
"""Delete a binary or preview file. file_type must be 'binary' or 'preview'."""
if file_type not in ("binary", "preview"):
@@ -113,7 +113,7 @@ async def delete_file(
@router.get("/{melody_id}/files")
async def get_files(
melody_id: str,
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("melodies", "view")),
):
"""Get storage file URLs for a melody."""
service.get_melody(melody_id)

103
backend/migrate_roles.py Normal file
View File

@@ -0,0 +1,103 @@
"""
Migration script to update existing admin users from old roles to new roles.
Old roles -> New roles:
superadmin -> sysadmin
melody_editor -> editor (with melodies full access)
device_manager -> editor (with devices + equipment full access)
user_manager -> editor (with app_users full access)
viewer -> user (view-only)
Usage:
python migrate_roles.py
python migrate_roles.py --dry-run (preview changes without applying)
"""
import argparse
import sys
from shared.firebase import init_firebase, get_db
ROLE_MAPPING = {
"superadmin": "sysadmin",
"melody_editor": "editor",
"device_manager": "editor",
"user_manager": "editor",
"viewer": "user",
}
FULL = {"view": True, "add": True, "edit": True, "delete": True}
VIEW_ONLY = {"view": True, "add": False, "edit": False, "delete": False}
PERMISSION_MAPPING = {
"melody_editor": {
"melodies": FULL,
"devices": VIEW_ONLY,
"app_users": VIEW_ONLY,
"equipment": VIEW_ONLY,
"mqtt": False,
},
"device_manager": {
"melodies": VIEW_ONLY,
"devices": FULL,
"app_users": VIEW_ONLY,
"equipment": FULL,
"mqtt": True,
},
"user_manager": {
"melodies": VIEW_ONLY,
"devices": VIEW_ONLY,
"app_users": FULL,
"equipment": VIEW_ONLY,
"mqtt": False,
},
"viewer": {
"melodies": VIEW_ONLY,
"devices": VIEW_ONLY,
"app_users": VIEW_ONLY,
"equipment": VIEW_ONLY,
"mqtt": False,
},
}
def migrate(dry_run=False):
init_firebase()
db = get_db()
if not db:
print("ERROR: Firebase initialization failed.")
sys.exit(1)
docs = db.collection("admin_users").get()
migrated = 0
for doc in docs:
data = doc.to_dict()
old_role = data.get("role", "")
if old_role in ROLE_MAPPING:
new_role = ROLE_MAPPING[old_role]
permissions = PERMISSION_MAPPING.get(old_role)
print(f" {data.get('email', '?'):30s} {old_role:20s} -> {new_role}")
if not dry_run:
update = {"role": new_role}
if permissions:
update["permissions"] = permissions
doc.reference.update(update)
migrated += 1
else:
print(f" {data.get('email', '?'):30s} {old_role:20s} (already migrated, skipping)")
action = "would be" if dry_run else "were"
print(f"\n{migrated} user(s) {action} migrated.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Migrate admin user roles")
parser.add_argument("--dry-run", action="store_true", help="Preview changes without applying")
args = parser.parse_args()
print("Migrating admin user roles...\n")
migrate(dry_run=args.dry_run)

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from typing import Optional
from auth.models import TokenPayload
from auth.dependencies import require_device_access, require_viewer
from auth.dependencies import require_permission
from mqtt.models import (
MqttCommandRequest, CommandSendResponse, MqttStatusResponse,
DeviceMqttStatus, LogListResponse, HeartbeatListResponse,
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/api/mqtt", tags=["mqtt"])
@router.get("/status", response_model=MqttStatusResponse)
async def get_all_device_status(
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
heartbeats = await db.get_latest_heartbeats()
now = datetime.now(timezone.utc)
@@ -47,7 +47,7 @@ async def get_all_device_status(
async def send_command(
device_serial: str,
body: MqttCommandRequest,
_user: TokenPayload = Depends(require_device_access),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
command_id = await db.insert_command(
device_serial=device_serial,
@@ -84,7 +84,7 @@ async def get_device_logs(
search: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
logs, total = await db.get_logs(
device_serial, level=level, search=search,
@@ -98,7 +98,7 @@ async def get_device_heartbeats(
device_serial: str,
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
heartbeats, total = await db.get_heartbeats(
device_serial, limit=limit, offset=offset,
@@ -111,7 +111,7 @@ async def get_device_commands(
device_serial: str,
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
commands, total = await db.get_commands(
device_serial, limit=limit, offset=offset,
@@ -129,12 +129,28 @@ async def mqtt_websocket(websocket: WebSocket):
try:
from auth.utils import decode_access_token
from shared.firebase import get_db
payload = decode_access_token(token)
role = payload.get("role", "")
allowed = {"superadmin", "device_manager", "viewer"}
if role not in allowed:
await websocket.close(code=4003, reason="Insufficient permissions")
return
# sysadmin and admin always have MQTT access
if role not in ("sysadmin", "admin"):
# Check MQTT permission for editor/user
user_sub = payload.get("sub", "")
db_inst = get_db()
if db_inst:
doc = db_inst.collection("admin_users").document(user_sub).get()
if doc.exists:
perms = doc.to_dict().get("permissions", {})
if not perms.get("mqtt", False):
await websocket.close(code=4003, reason="MQTT access denied")
return
else:
await websocket.close(code=4003, reason="User not found")
return
else:
await websocket.close(code=4003, reason="Service unavailable")
return
except Exception:
await websocket.close(code=4001, reason="Invalid token")
return

View File

@@ -34,15 +34,15 @@ def seed_superadmin(email: str, password: str, name: str):
"email": email,
"hashed_password": hash_password(password),
"name": name,
"role": "superadmin",
"role": "sysadmin",
"is_active": True,
}
doc_ref = db.collection("admin_users").add(user_data)
print(f"Superadmin created successfully!")
print(f"SysAdmin created successfully!")
print(f" Email: {email}")
print(f" Name: {name}")
print(f" Role: superadmin")
print(f" Role: sysadmin")
print(f" Doc ID: {doc_ref[1].id}")

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends
from auth.models import TokenPayload
from auth.dependencies import require_melody_access, require_viewer
from auth.dependencies import require_permission
from settings.models import MelodySettings, MelodySettingsUpdate
from settings import service
@@ -9,7 +9,7 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("/melody", response_model=MelodySettings)
async def get_melody_settings(
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("melodies", "view")),
):
return service.get_melody_settings()
@@ -17,6 +17,6 @@ async def get_melody_settings(
@router.put("/melody", response_model=MelodySettings)
async def update_melody_settings(
body: MelodySettingsUpdate,
_user: TokenPayload = Depends(require_melody_access),
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
):
return service.update_melody_settings(body)

View File

37
backend/staff/models.py Normal file
View File

@@ -0,0 +1,37 @@
from pydantic import BaseModel
from typing import Optional
from auth.models import StaffPermissions
class StaffCreate(BaseModel):
email: str
password: str
name: str
role: str # sysadmin, admin, editor, user
permissions: Optional[StaffPermissions] = None
class StaffUpdate(BaseModel):
email: Optional[str] = None
name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
permissions: Optional[StaffPermissions] = None
class StaffPasswordUpdate(BaseModel):
new_password: str
class StaffResponse(BaseModel):
id: str
email: str
name: str
role: str
is_active: bool
permissions: Optional[dict] = None
class StaffListResponse(BaseModel):
staff: list[StaffResponse]
total: int

82
backend/staff/router.py Normal file
View File

@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, Query
from auth.dependencies import get_current_user, require_staff_management
from auth.models import TokenPayload
from staff import service
from staff.models import (
StaffCreate, StaffUpdate, StaffPasswordUpdate,
StaffResponse, StaffListResponse,
)
router = APIRouter(prefix="/api/staff", tags=["staff"])
@router.get("/me", response_model=StaffResponse)
async def get_current_staff(current_user: TokenPayload = Depends(get_current_user)):
return await service.get_staff_me(current_user.sub)
@router.get("", response_model=StaffListResponse)
async def list_staff(
search: str = Query(None),
role: str = Query(None),
current_user: TokenPayload = Depends(require_staff_management),
):
return await service.list_staff(search=search, role_filter=role)
@router.get("/{staff_id}", response_model=StaffResponse)
async def get_staff(
staff_id: str,
current_user: TokenPayload = Depends(require_staff_management),
):
return await service.get_staff(staff_id)
@router.post("", response_model=StaffResponse)
async def create_staff(
body: StaffCreate,
current_user: TokenPayload = Depends(require_staff_management),
):
return await service.create_staff(
data=body.model_dump(),
current_user_role=current_user.role,
)
@router.put("/{staff_id}", response_model=StaffResponse)
async def update_staff(
staff_id: str,
body: StaffUpdate,
current_user: TokenPayload = Depends(require_staff_management),
):
return await service.update_staff(
staff_id=staff_id,
data=body.model_dump(exclude_unset=True),
current_user_role=current_user.role,
current_user_id=current_user.sub,
)
@router.put("/{staff_id}/password")
async def update_staff_password(
staff_id: str,
body: StaffPasswordUpdate,
current_user: TokenPayload = Depends(require_staff_management),
):
return await service.update_staff_password(
staff_id=staff_id,
new_password=body.new_password,
current_user_role=current_user.role,
)
@router.delete("/{staff_id}")
async def delete_staff(
staff_id: str,
current_user: TokenPayload = Depends(require_staff_management),
):
return await service.delete_staff(
staff_id=staff_id,
current_user_role=current_user.role,
current_user_id=current_user.sub,
)

178
backend/staff/service.py Normal file
View File

@@ -0,0 +1,178 @@
from shared.firebase import get_db
from auth.utils import hash_password
from auth.models import default_permissions_for_role
from shared.exceptions import NotFoundError, AuthorizationError
import uuid
VALID_ROLES = ("sysadmin", "admin", "editor", "user")
def _staff_doc_to_response(doc_id: str, data: dict) -> dict:
return {
"id": doc_id,
"email": data.get("email", ""),
"name": data.get("name", ""),
"role": data.get("role", ""),
"is_active": data.get("is_active", True),
"permissions": data.get("permissions"),
}
async def list_staff(search: str = None, role_filter: str = None) -> dict:
db = get_db()
ref = db.collection("admin_users")
docs = ref.get()
staff = []
for doc in docs:
data = doc.to_dict()
if search:
s = search.lower()
if s not in (data.get("name", "").lower()) and s not in (data.get("email", "").lower()):
continue
if role_filter:
if data.get("role") != role_filter:
continue
staff.append(_staff_doc_to_response(doc.id, data))
return {"staff": staff, "total": len(staff)}
async def get_staff(staff_id: str) -> dict:
db = get_db()
doc = db.collection("admin_users").document(staff_id).get()
if not doc.exists:
raise NotFoundError("Staff member not found")
return _staff_doc_to_response(doc.id, doc.to_dict())
async def get_staff_me(user_sub: str) -> dict:
db = get_db()
doc = db.collection("admin_users").document(user_sub).get()
if not doc.exists:
raise NotFoundError("Staff member not found")
return _staff_doc_to_response(doc.id, doc.to_dict())
async def create_staff(data: dict, current_user_role: str) -> dict:
role = data.get("role", "user")
if role not in VALID_ROLES:
raise AuthorizationError(f"Invalid role: {role}")
# Admin cannot create sysadmin
if current_user_role == "admin" and role == "sysadmin":
raise AuthorizationError("Admin cannot create sysadmin accounts")
db = get_db()
# Check for duplicate email
existing = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get()
if existing:
raise AuthorizationError("A staff member with this email already exists")
uid = str(uuid.uuid4())
hashed = hash_password(data["password"])
# Set default permissions for editor/user if not provided
permissions = data.get("permissions")
if permissions is None and role in ("editor", "user"):
permissions = default_permissions_for_role(role)
doc_data = {
"uid": uid,
"email": data["email"],
"hashed_password": hashed,
"name": data["name"],
"role": role,
"is_active": True,
"permissions": permissions,
}
doc_ref = db.collection("admin_users").document(uid)
doc_ref.set(doc_data)
return _staff_doc_to_response(uid, doc_data)
async def update_staff(staff_id: str, data: dict, current_user_role: str, current_user_id: str) -> dict:
db = get_db()
doc_ref = db.collection("admin_users").document(staff_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Staff member not found")
existing = doc.to_dict()
# Admin cannot edit sysadmin accounts
if current_user_role == "admin" and existing.get("role") == "sysadmin":
raise AuthorizationError("Admin cannot modify sysadmin accounts")
# Admin cannot promote to sysadmin
if current_user_role == "admin" and data.get("role") == "sysadmin":
raise AuthorizationError("Admin cannot promote to sysadmin")
update_data = {}
if data.get("email") is not None:
# Check for duplicate email
others = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get()
for other in others:
if other.id != staff_id:
raise AuthorizationError("A staff member with this email already exists")
update_data["email"] = data["email"]
if data.get("name") is not None:
update_data["name"] = data["name"]
if data.get("role") is not None:
if data["role"] not in VALID_ROLES:
raise AuthorizationError(f"Invalid role: {data['role']}")
update_data["role"] = data["role"]
if data.get("is_active") is not None:
update_data["is_active"] = data["is_active"]
if "permissions" in data:
update_data["permissions"] = data["permissions"]
if update_data:
doc_ref.update(update_data)
updated = {**existing, **update_data}
return _staff_doc_to_response(staff_id, updated)
async def update_staff_password(staff_id: str, new_password: str, current_user_role: str) -> dict:
db = get_db()
doc_ref = db.collection("admin_users").document(staff_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Staff member not found")
existing = doc.to_dict()
# Admin cannot change sysadmin password
if current_user_role == "admin" and existing.get("role") == "sysadmin":
raise AuthorizationError("Admin cannot modify sysadmin accounts")
hashed = hash_password(new_password)
doc_ref.update({"hashed_password": hashed})
return {"message": "Password updated successfully"}
async def delete_staff(staff_id: str, current_user_role: str, current_user_id: str) -> dict:
db = get_db()
doc_ref = db.collection("admin_users").document(staff_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Staff member not found")
existing = doc.to_dict()
# Cannot delete self
if staff_id == current_user_id:
raise AuthorizationError("Cannot delete your own account")
# Admin cannot delete sysadmin
if current_user_role == "admin" and existing.get("role") == "sysadmin":
raise AuthorizationError("Admin cannot delete sysadmin accounts")
doc_ref.delete()
return {"message": "Staff member deleted"}

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, Query
from typing import Optional, List
from auth.models import TokenPayload
from auth.dependencies import require_user_access, require_viewer
from auth.dependencies import require_permission
from users.models import (
UserCreate, UserUpdate, UserInDB, UserListResponse,
)
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/api/users", tags=["users"])
async def list_users(
search: Optional[str] = Query(None),
status: Optional[str] = Query(None),
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("app_users", "view")),
):
users = service.list_users(search=search, status=status)
return UserListResponse(users=users, total=len(users))
@@ -23,7 +23,7 @@ async def list_users(
@router.get("/{user_id}", response_model=UserInDB)
async def get_user(
user_id: str,
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("app_users", "view")),
):
return service.get_user(user_id)
@@ -31,7 +31,7 @@ async def get_user(
@router.post("", response_model=UserInDB, status_code=201)
async def create_user(
body: UserCreate,
_user: TokenPayload = Depends(require_user_access),
_user: TokenPayload = Depends(require_permission("app_users", "add")),
):
return service.create_user(body)
@@ -40,7 +40,7 @@ async def create_user(
async def update_user(
user_id: str,
body: UserUpdate,
_user: TokenPayload = Depends(require_user_access),
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
):
return service.update_user(user_id, body)
@@ -48,7 +48,7 @@ async def update_user(
@router.delete("/{user_id}", status_code=204)
async def delete_user(
user_id: str,
_user: TokenPayload = Depends(require_user_access),
_user: TokenPayload = Depends(require_permission("app_users", "delete")),
):
service.delete_user(user_id)
@@ -56,7 +56,7 @@ async def delete_user(
@router.post("/{user_id}/block", response_model=UserInDB)
async def block_user(
user_id: str,
_user: TokenPayload = Depends(require_user_access),
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
):
return service.block_user(user_id)
@@ -64,7 +64,7 @@ async def block_user(
@router.post("/{user_id}/unblock", response_model=UserInDB)
async def unblock_user(
user_id: str,
_user: TokenPayload = Depends(require_user_access),
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
):
return service.unblock_user(user_id)
@@ -72,7 +72,7 @@ async def unblock_user(
@router.get("/{user_id}/devices", response_model=List[dict])
async def get_user_devices(
user_id: str,
_user: TokenPayload = Depends(require_viewer),
_user: TokenPayload = Depends(require_permission("app_users", "view")),
):
return service.get_user_devices(user_id)
@@ -81,7 +81,7 @@ async def get_user_devices(
async def assign_device(
user_id: str,
device_id: str,
_user: TokenPayload = Depends(require_user_access),
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
):
return service.assign_device(user_id, device_id)
@@ -90,6 +90,6 @@ async def assign_device(
async def unassign_device(
user_id: str,
device_id: str,
_user: TokenPayload = Depends(require_user_access),
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
):
return service.unassign_device(user_id, device_id)