diff --git a/backend/auth/dependencies.py b/backend/auth/dependencies.py index dd998c5..95bf2b3 100644 --- a/backend/auth/dependencies.py +++ b/backend/auth/dependencies.py @@ -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, ) diff --git a/backend/auth/models.py b/backend/auth/models.py index 1376049..b2c6855 100644 --- a/backend/auth/models.py +++ b/backend/auth/models.py @@ -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): diff --git a/backend/auth/router.py b/backend/auth/router.py index 0713ef7..331916d 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -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, ) diff --git a/backend/devices/router.py b/backend/devices/router.py index 39a00ee..efce87d 100644 --- a/backend/devices/router.py +++ b/backend/devices/router.py @@ -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) diff --git a/backend/equipment/router.py b/backend/equipment/router.py index 91942a0..650fbc8 100644 --- a/backend/equipment/router.py +++ b/backend/equipment/router.py @@ -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) diff --git a/backend/main.py b/backend/main.py index c01f98d..3cbbdf8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/backend/melodies/router.py b/backend/melodies/router.py index 41d3c86..7f22009 100644 --- a/backend/melodies/router.py +++ b/backend/melodies/router.py @@ -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) diff --git a/backend/migrate_roles.py b/backend/migrate_roles.py new file mode 100644 index 0000000..2fc712c --- /dev/null +++ b/backend/migrate_roles.py @@ -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) diff --git a/backend/mqtt/router.py b/backend/mqtt/router.py index 9f31786..52720ef 100644 --- a/backend/mqtt/router.py +++ b/backend/mqtt/router.py @@ -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 diff --git a/backend/seed_admin.py b/backend/seed_admin.py index e7d0882..f6e544d 100644 --- a/backend/seed_admin.py +++ b/backend/seed_admin.py @@ -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}") diff --git a/backend/settings/router.py b/backend/settings/router.py index 0425d9d..cfd7163 100644 --- a/backend/settings/router.py +++ b/backend/settings/router.py @@ -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) diff --git a/backend/staff/__init__.py b/backend/staff/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/staff/models.py b/backend/staff/models.py new file mode 100644 index 0000000..dafac5a --- /dev/null +++ b/backend/staff/models.py @@ -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 diff --git a/backend/staff/router.py b/backend/staff/router.py new file mode 100644 index 0000000..e286d7a --- /dev/null +++ b/backend/staff/router.py @@ -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, + ) diff --git a/backend/staff/service.py b/backend/staff/service.py new file mode 100644 index 0000000..bd82c9f --- /dev/null +++ b/backend/staff/service.py @@ -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"} diff --git a/backend/users/router.py b/backend/users/router.py index 4f05a5a..d66f24d 100644 --- a/backend/users/router.py +++ b/backend/users/router.py @@ -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) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 574dada..8efc189 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -18,6 +18,9 @@ import LogViewer from "./mqtt/LogViewer"; import NoteList from "./equipment/NoteList"; import NoteDetail from "./equipment/NoteDetail"; import NoteForm from "./equipment/NoteForm"; +import StaffList from "./settings/StaffList"; +import StaffDetail from "./settings/StaffDetail"; +import StaffForm from "./settings/StaffForm"; function ProtectedRoute({ children }) { const { user, loading } = useAuth(); @@ -37,6 +40,52 @@ function ProtectedRoute({ children }) { return children; } +function PermissionGate({ section, action = "view", children }) { + const { hasPermission } = useAuth(); + + if (!hasPermission(section, action)) { + return ( +
+ You don't have permission to access this feature. + Please contact an administrator if you need access. +
++ You don't have permission to access this feature. + Please contact an administrator if you need access. +
+Loading users...
+ ) : deviceUsers.length === 0 ? ( +No users assigned to this device.
+ ) : ( ++ {user.display_name || user.email || "Unknown User"} +
+ {user.email && user.display_name && ( +{user.email}
+ )} + {user.user_id && ( +{user.user_id}
+ )} +Loading users...
- ) : deviceUsers.length === 0 ? ( -No users assigned to this device.
- ) : ( -- {user.display_name || user.email || "Unknown User"} -
- {user.email && user.display_name && ( -{user.email}
- )} - {user.user_id && ( -{user.user_id}
- )} -