Added Roles and Permissions. Some minor UI fixes
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
103
backend/migrate_roles.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
0
backend/staff/__init__.py
Normal file
0
backend/staff/__init__.py
Normal file
37
backend/staff/models.py
Normal file
37
backend/staff/models.py
Normal 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
82
backend/staff/router.py
Normal 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
178
backend/staff/service.py
Normal 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"}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user