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.utils import decode_access_token
from auth.models import TokenPayload, Role from auth.models import TokenPayload, Role
from shared.exceptions import AuthenticationError, AuthorizationError from shared.exceptions import AuthenticationError, AuthorizationError
from shared.firebase import get_db
security = HTTPBearer() security = HTTPBearer()
@@ -28,7 +29,7 @@ def require_roles(*allowed_roles: Role):
async def role_checker( async def role_checker(
current_user: TokenPayload = Depends(get_current_user), current_user: TokenPayload = Depends(get_current_user),
) -> TokenPayload: ) -> TokenPayload:
if current_user.role == Role.superadmin: if current_user.role == Role.sysadmin:
return current_user return current_user
if current_user.role not in [r.value for r in allowed_roles]: if current_user.role not in [r.value for r in allowed_roles]:
raise AuthorizationError() raise AuthorizationError()
@@ -36,12 +37,63 @@ def require_roles(*allowed_roles: Role):
return role_checker 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 # Pre-built convenience dependencies
require_superadmin = require_roles(Role.superadmin) require_sysadmin = require_roles(Role.sysadmin)
require_melody_access = require_roles(Role.superadmin, Role.melody_editor) require_admin_or_above = require_roles(Role.sysadmin, Role.admin)
require_device_access = require_roles(Role.superadmin, Role.device_manager)
require_user_access = require_roles(Role.superadmin, Role.user_manager) # Staff management: only sysadmin and admin
require_viewer = require_roles( require_staff_management = require_roles(Role.sysadmin, Role.admin)
Role.superadmin, Role.melody_editor, Role.device_manager,
Role.user_manager, Role.viewer, # 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): class Role(str, Enum):
superadmin = "superadmin" sysadmin = "sysadmin"
melody_editor = "melody_editor" admin = "admin"
device_manager = "device_manager" editor = "editor"
user_manager = "user_manager" user = "user"
viewer = "viewer"
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): class AdminUserInDB(BaseModel):
@@ -18,6 +56,7 @@ class AdminUserInDB(BaseModel):
name: str name: str
role: Role role: Role
is_active: bool = True is_active: bool = True
permissions: Optional[StaffPermissions] = None
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
@@ -30,6 +69,7 @@ class TokenResponse(BaseModel):
token_type: str = "bearer" token_type: str = "bearer"
role: str role: str
name: str name: str
permissions: Optional[dict] = None
class TokenPayload(BaseModel): class TokenPayload(BaseModel):

View File

@@ -28,15 +28,32 @@ async def login(body: LoginRequest):
if not verify_password(body.password, user_data["hashed_password"]): if not verify_password(body.password, user_data["hashed_password"]):
raise AuthenticationError("Invalid email or 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({ token = create_access_token({
"sub": doc.id, "sub": doc.id,
"email": user_data["email"], "email": user_data["email"],
"role": user_data["role"], "role": role,
"name": user_data["name"], "name": user_data["name"],
}) })
# Get permissions for editor/user roles
permissions = None
if role in ("editor", "user"):
permissions = user_data.get("permissions")
return TokenResponse( return TokenResponse(
access_token=token, access_token=token,
role=user_data["role"], role=role,
name=user_data["name"], name=user_data["name"],
permissions=permissions,
) )

View File

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

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from typing import Optional from typing import Optional
from auth.models import TokenPayload from auth.models import TokenPayload
from auth.dependencies import require_device_access, require_viewer from auth.dependencies import require_permission
from equipment.models import ( from equipment.models import (
NoteCreate, NoteUpdate, NoteInDB, NoteListResponse, NoteCreate, NoteUpdate, NoteInDB, NoteListResponse,
) )
@@ -16,7 +16,7 @@ async def list_notes(
category: Optional[str] = Query(None), category: Optional[str] = Query(None),
device_id: Optional[str] = Query(None), device_id: Optional[str] = Query(None),
user_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( notes = service.list_notes(
search=search, category=category, search=search, category=category,
@@ -28,7 +28,7 @@ async def list_notes(
@router.get("/{note_id}", response_model=NoteInDB) @router.get("/{note_id}", response_model=NoteInDB)
async def get_note( async def get_note(
note_id: str, note_id: str,
_user: TokenPayload = Depends(require_viewer), _user: TokenPayload = Depends(require_permission("equipment", "view")),
): ):
return service.get_note(note_id) return service.get_note(note_id)
@@ -36,7 +36,7 @@ async def get_note(
@router.post("", response_model=NoteInDB, status_code=201) @router.post("", response_model=NoteInDB, status_code=201)
async def create_note( async def create_note(
body: NoteCreate, 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) return service.create_note(body, created_by=_user.name or _user.email)
@@ -45,7 +45,7 @@ async def create_note(
async def update_note( async def update_note(
note_id: str, note_id: str,
body: NoteUpdate, body: NoteUpdate,
_user: TokenPayload = Depends(require_device_access), _user: TokenPayload = Depends(require_permission("equipment", "edit")),
): ):
return service.update_note(note_id, body) return service.update_note(note_id, body)
@@ -53,6 +53,6 @@ async def update_note(
@router.delete("/{note_id}", status_code=204) @router.delete("/{note_id}", status_code=204)
async def delete_note( async def delete_note(
note_id: str, note_id: str,
_user: TokenPayload = Depends(require_device_access), _user: TokenPayload = Depends(require_permission("equipment", "delete")),
): ):
service.delete_note(note_id) 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 users.router import router as users_router
from mqtt.router import router as mqtt_router from mqtt.router import router as mqtt_router
from equipment.router import router as equipment_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.client import mqtt_manager
from mqtt import database as mqtt_db from mqtt import database as mqtt_db
@@ -35,6 +36,7 @@ app.include_router(settings_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(mqtt_router) app.include_router(mqtt_router)
app.include_router(equipment_router) app.include_router(equipment_router)
app.include_router(staff_router)
@app.on_event("startup") @app.on_event("startup")

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException
from typing import Optional from typing import Optional
from auth.models import TokenPayload from auth.models import TokenPayload
from auth.dependencies import require_melody_access, require_viewer from auth.dependencies import require_permission
from melodies.models import ( from melodies.models import (
MelodyCreate, MelodyUpdate, MelodyInDB, MelodyListResponse, MelodyInfo, MelodyCreate, MelodyUpdate, MelodyInDB, MelodyListResponse, MelodyInfo,
) )
@@ -16,7 +16,7 @@ async def list_melodies(
type: Optional[str] = Query(None), type: Optional[str] = Query(None),
tone: Optional[str] = Query(None), tone: Optional[str] = Query(None),
total_notes: Optional[int] = Query(None), total_notes: Optional[int] = Query(None),
_user: TokenPayload = Depends(require_viewer), _user: TokenPayload = Depends(require_permission("melodies", "view")),
): ):
melodies = service.list_melodies( melodies = service.list_melodies(
search=search, search=search,
@@ -30,7 +30,7 @@ async def list_melodies(
@router.get("/{melody_id}", response_model=MelodyInDB) @router.get("/{melody_id}", response_model=MelodyInDB)
async def get_melody( async def get_melody(
melody_id: str, melody_id: str,
_user: TokenPayload = Depends(require_viewer), _user: TokenPayload = Depends(require_permission("melodies", "view")),
): ):
return service.get_melody(melody_id) return service.get_melody(melody_id)
@@ -38,7 +38,7 @@ async def get_melody(
@router.post("", response_model=MelodyInDB, status_code=201) @router.post("", response_model=MelodyInDB, status_code=201)
async def create_melody( async def create_melody(
body: MelodyCreate, body: MelodyCreate,
_user: TokenPayload = Depends(require_melody_access), _user: TokenPayload = Depends(require_permission("melodies", "add")),
): ):
return service.create_melody(body) return service.create_melody(body)
@@ -47,7 +47,7 @@ async def create_melody(
async def update_melody( async def update_melody(
melody_id: str, melody_id: str,
body: MelodyUpdate, body: MelodyUpdate,
_user: TokenPayload = Depends(require_melody_access), _user: TokenPayload = Depends(require_permission("melodies", "edit")),
): ):
return service.update_melody(melody_id, body) return service.update_melody(melody_id, body)
@@ -55,7 +55,7 @@ async def update_melody(
@router.delete("/{melody_id}", status_code=204) @router.delete("/{melody_id}", status_code=204)
async def delete_melody( async def delete_melody(
melody_id: str, melody_id: str,
_user: TokenPayload = Depends(require_melody_access), _user: TokenPayload = Depends(require_permission("melodies", "delete")),
): ):
service.delete_melody(melody_id) service.delete_melody(melody_id)
@@ -65,7 +65,7 @@ async def upload_file(
melody_id: str, melody_id: str,
file_type: str, file_type: str,
file: UploadFile = File(...), 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'.""" """Upload a binary or preview file. file_type must be 'binary' or 'preview'."""
if file_type not in ("binary", "preview"): if file_type not in ("binary", "preview"):
@@ -100,7 +100,7 @@ async def upload_file(
async def delete_file( async def delete_file(
melody_id: str, melody_id: str,
file_type: 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'.""" """Delete a binary or preview file. file_type must be 'binary' or 'preview'."""
if file_type not in ("binary", "preview"): if file_type not in ("binary", "preview"):
@@ -113,7 +113,7 @@ async def delete_file(
@router.get("/{melody_id}/files") @router.get("/{melody_id}/files")
async def get_files( async def get_files(
melody_id: str, melody_id: str,
_user: TokenPayload = Depends(require_viewer), _user: TokenPayload = Depends(require_permission("melodies", "view")),
): ):
"""Get storage file URLs for a melody.""" """Get storage file URLs for a melody."""
service.get_melody(melody_id) 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 fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from typing import Optional from typing import Optional
from auth.models import TokenPayload from auth.models import TokenPayload
from auth.dependencies import require_device_access, require_viewer from auth.dependencies import require_permission
from mqtt.models import ( from mqtt.models import (
MqttCommandRequest, CommandSendResponse, MqttStatusResponse, MqttCommandRequest, CommandSendResponse, MqttStatusResponse,
DeviceMqttStatus, LogListResponse, HeartbeatListResponse, DeviceMqttStatus, LogListResponse, HeartbeatListResponse,
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/api/mqtt", tags=["mqtt"])
@router.get("/status", response_model=MqttStatusResponse) @router.get("/status", response_model=MqttStatusResponse)
async def get_all_device_status( async def get_all_device_status(
_user: TokenPayload = Depends(require_viewer), _user: TokenPayload = Depends(require_permission("mqtt", "view")),
): ):
heartbeats = await db.get_latest_heartbeats() heartbeats = await db.get_latest_heartbeats()
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -47,7 +47,7 @@ async def get_all_device_status(
async def send_command( async def send_command(
device_serial: str, device_serial: str,
body: MqttCommandRequest, body: MqttCommandRequest,
_user: TokenPayload = Depends(require_device_access), _user: TokenPayload = Depends(require_permission("mqtt", "view")),
): ):
command_id = await db.insert_command( command_id = await db.insert_command(
device_serial=device_serial, device_serial=device_serial,
@@ -84,7 +84,7 @@ async def get_device_logs(
search: Optional[str] = Query(None), search: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
_user: TokenPayload = Depends(require_viewer), _user: TokenPayload = Depends(require_permission("mqtt", "view")),
): ):
logs, total = await db.get_logs( logs, total = await db.get_logs(
device_serial, level=level, search=search, device_serial, level=level, search=search,
@@ -98,7 +98,7 @@ async def get_device_heartbeats(
device_serial: str, device_serial: str,
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
_user: TokenPayload = Depends(require_viewer), _user: TokenPayload = Depends(require_permission("mqtt", "view")),
): ):
heartbeats, total = await db.get_heartbeats( heartbeats, total = await db.get_heartbeats(
device_serial, limit=limit, offset=offset, device_serial, limit=limit, offset=offset,
@@ -111,7 +111,7 @@ async def get_device_commands(
device_serial: str, device_serial: str,
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
_user: TokenPayload = Depends(require_viewer), _user: TokenPayload = Depends(require_permission("mqtt", "view")),
): ):
commands, total = await db.get_commands( commands, total = await db.get_commands(
device_serial, limit=limit, offset=offset, device_serial, limit=limit, offset=offset,
@@ -129,11 +129,27 @@ async def mqtt_websocket(websocket: WebSocket):
try: try:
from auth.utils import decode_access_token from auth.utils import decode_access_token
from shared.firebase import get_db
payload = decode_access_token(token) payload = decode_access_token(token)
role = payload.get("role", "") role = payload.get("role", "")
allowed = {"superadmin", "device_manager", "viewer"}
if role not in allowed: # sysadmin and admin always have MQTT access
await websocket.close(code=4003, reason="Insufficient permissions") 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 return
except Exception: except Exception:
await websocket.close(code=4001, reason="Invalid token") await websocket.close(code=4001, reason="Invalid token")

View File

@@ -34,15 +34,15 @@ def seed_superadmin(email: str, password: str, name: str):
"email": email, "email": email,
"hashed_password": hash_password(password), "hashed_password": hash_password(password),
"name": name, "name": name,
"role": "superadmin", "role": "sysadmin",
"is_active": True, "is_active": True,
} }
doc_ref = db.collection("admin_users").add(user_data) 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" Email: {email}")
print(f" Name: {name}") print(f" Name: {name}")
print(f" Role: superadmin") print(f" Role: sysadmin")
print(f" Doc ID: {doc_ref[1].id}") print(f" Doc ID: {doc_ref[1].id}")

View File

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

View File

@@ -18,6 +18,9 @@ import LogViewer from "./mqtt/LogViewer";
import NoteList from "./equipment/NoteList"; import NoteList from "./equipment/NoteList";
import NoteDetail from "./equipment/NoteDetail"; import NoteDetail from "./equipment/NoteDetail";
import NoteForm from "./equipment/NoteForm"; import NoteForm from "./equipment/NoteForm";
import StaffList from "./settings/StaffList";
import StaffDetail from "./settings/StaffDetail";
import StaffForm from "./settings/StaffForm";
function ProtectedRoute({ children }) { function ProtectedRoute({ children }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -37,6 +40,52 @@ function ProtectedRoute({ children }) {
return children; return children;
} }
function PermissionGate({ section, action = "view", children }) {
const { hasPermission } = useAuth();
if (!hasPermission(section, action)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<div className="rounded-lg border p-8 text-center max-w-md" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<svg className="w-12 h-12 mx-auto mb-4" style={{ color: "var(--text-muted)" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<h2 className="text-lg font-semibold mb-2" style={{ color: "var(--text-heading)" }}>Access Denied</h2>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
You don't have permission to access this feature.
Please contact an administrator if you need access.
</p>
</div>
</div>
);
}
return children;
}
function RoleGate({ roles, children }) {
const { hasRole } = useAuth();
if (!hasRole(...roles)) {
return (
<div className="flex flex-col items-center justify-center py-20">
<div className="rounded-lg border p-8 text-center max-w-md" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<svg className="w-12 h-12 mx-auto mb-4" style={{ color: "var(--text-muted)" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<h2 className="text-lg font-semibold mb-2" style={{ color: "var(--text-heading)" }}>Access Denied</h2>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
You don't have permission to access this feature.
Please contact an administrator if you need access.
</p>
</div>
</div>
);
}
return children;
}
function DashboardPage() { function DashboardPage() {
const { user } = useAuth(); const { user } = useAuth();
return ( return (
@@ -62,26 +111,43 @@ export default function App() {
} }
> >
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="melodies" element={<MelodyList />} />
<Route path="melodies/settings" element={<MelodySettings />} /> {/* Melodies */}
<Route path="melodies/new" element={<MelodyForm />} /> <Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></PermissionGate>} />
<Route path="melodies/:id" element={<MelodyDetail />} /> <Route path="melodies/settings" element={<PermissionGate section="melodies"><MelodySettings /></PermissionGate>} />
<Route path="melodies/:id/edit" element={<MelodyForm />} /> <Route path="melodies/new" element={<PermissionGate section="melodies" action="add"><MelodyForm /></PermissionGate>} />
<Route path="devices" element={<DeviceList />} /> <Route path="melodies/:id" element={<PermissionGate section="melodies"><MelodyDetail /></PermissionGate>} />
<Route path="devices/new" element={<DeviceForm />} /> <Route path="melodies/:id/edit" element={<PermissionGate section="melodies" action="edit"><MelodyForm /></PermissionGate>} />
<Route path="devices/:id" element={<DeviceDetail />} />
<Route path="devices/:id/edit" element={<DeviceForm />} /> {/* Devices */}
<Route path="users" element={<UserList />} /> <Route path="devices" element={<PermissionGate section="devices"><DeviceList /></PermissionGate>} />
<Route path="users/new" element={<UserForm />} /> <Route path="devices/new" element={<PermissionGate section="devices" action="add"><DeviceForm /></PermissionGate>} />
<Route path="users/:id" element={<UserDetail />} /> <Route path="devices/:id" element={<PermissionGate section="devices"><DeviceDetail /></PermissionGate>} />
<Route path="users/:id/edit" element={<UserForm />} /> <Route path="devices/:id/edit" element={<PermissionGate section="devices" action="edit"><DeviceForm /></PermissionGate>} />
<Route path="mqtt" element={<MqttDashboard />} />
<Route path="mqtt/commands" element={<CommandPanel />} /> {/* App Users */}
<Route path="mqtt/logs" element={<LogViewer />} /> <Route path="users" element={<PermissionGate section="app_users"><UserList /></PermissionGate>} />
<Route path="equipment/notes" element={<NoteList />} /> <Route path="users/new" element={<PermissionGate section="app_users" action="add"><UserForm /></PermissionGate>} />
<Route path="equipment/notes/new" element={<NoteForm />} /> <Route path="users/:id" element={<PermissionGate section="app_users"><UserDetail /></PermissionGate>} />
<Route path="equipment/notes/:id" element={<NoteDetail />} /> <Route path="users/:id/edit" element={<PermissionGate section="app_users" action="edit"><UserForm /></PermissionGate>} />
<Route path="equipment/notes/:id/edit" element={<NoteForm />} />
{/* MQTT */}
<Route path="mqtt" element={<PermissionGate section="mqtt"><MqttDashboard /></PermissionGate>} />
<Route path="mqtt/commands" element={<PermissionGate section="mqtt"><CommandPanel /></PermissionGate>} />
<Route path="mqtt/logs" element={<PermissionGate section="mqtt"><LogViewer /></PermissionGate>} />
{/* Equipment Notes */}
<Route path="equipment/notes" element={<PermissionGate section="equipment"><NoteList /></PermissionGate>} />
<Route path="equipment/notes/new" element={<PermissionGate section="equipment" action="add"><NoteForm /></PermissionGate>} />
<Route path="equipment/notes/:id" element={<PermissionGate section="equipment"><NoteDetail /></PermissionGate>} />
<Route path="equipment/notes/:id/edit" element={<PermissionGate section="equipment" action="edit"><NoteForm /></PermissionGate>} />
{/* Settings - Staff Management */}
<Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} />
<Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
<Route path="settings/staff/:id" element={<RoleGate roles={["sysadmin", "admin"]}><StaffDetail /></RoleGate>} />
<Route path="settings/staff/:id/edit" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -36,9 +36,26 @@ export function AuthProvider({ children }) {
const login = async (email, password) => { const login = async (email, password) => {
const data = await api.post("/auth/login", { email, password }); const data = await api.post("/auth/login", { email, password });
localStorage.setItem("access_token", data.access_token); localStorage.setItem("access_token", data.access_token);
const userInfo = { name: data.name, role: data.role }; const userInfo = {
name: data.name,
role: data.role,
permissions: data.permissions || null,
};
localStorage.setItem("user", JSON.stringify(userInfo)); localStorage.setItem("user", JSON.stringify(userInfo));
setUser(userInfo); setUser(userInfo);
// Fetch full profile from /staff/me for up-to-date permissions
try {
const me = await api.get("/staff/me");
if (me.permissions) {
const updated = { ...userInfo, permissions: me.permissions };
localStorage.setItem("user", JSON.stringify(updated));
setUser(updated);
}
} catch {
// Non-critical, permissions from login response are used
}
return data; return data;
}; };
@@ -50,12 +67,30 @@ export function AuthProvider({ children }) {
const hasRole = (...roles) => { const hasRole = (...roles) => {
if (!user) return false; if (!user) return false;
if (user.role === "superadmin") return true; if (user.role === "sysadmin") return true;
return roles.includes(user.role); return roles.includes(user.role);
}; };
const hasPermission = (section, action) => {
if (!user) return false;
// sysadmin and admin have full access
if (user.role === "sysadmin" || user.role === "admin") return true;
const perms = user.permissions;
if (!perms) return false;
// MQTT is a global flag
if (section === "mqtt") {
return !!perms.mqtt;
}
const sectionPerms = perms[section];
if (!sectionPerms) return false;
return !!sectionPerms[action];
};
return ( return (
<AuthContext.Provider value={{ user, login, logout, loading, hasRole }}> <AuthContext.Provider value={{ user, login, logout, loading, hasRole, hasPermission }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -165,8 +165,8 @@ function formatCoordinates(coords) {
export default function DeviceDetail() { export default function DeviceDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasRole("superadmin", "device_manager"); const canEdit = hasPermission("devices", "edit");
const [device, setDevice] = useState(null); const [device, setDevice] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -298,9 +298,7 @@ export default function DeviceDetail() {
)} )}
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6"> <div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-6">
{/* Left column */}
<div className="space-y-6">
{/* Basic Information */} {/* Basic Information */}
<SectionCard title="Basic Information"> <SectionCard title="Basic Information">
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4"> <dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
@@ -315,51 +313,23 @@ export default function DeviceDetail() {
</span> </span>
)} )}
</Field> </Field>
<Field label="Events On">
<BoolBadge value={device.events_on} />
</Field>
<Field label="Document ID"> <Field label="Document ID">
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.id}</span> <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.id}</span>
</Field> </Field>
</dl> </dl>
</SectionCard> </SectionCard>
{/* Location */} {/* Misc */}
<SectionCard title="Location"> <SectionCard title="Misc">
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Location">{device.device_location}</Field> <Field label="Automated Events"><BoolBadge value={device.events_on} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Coordinates"> <Field label="Device Locale"><span className="capitalize">{attr.deviceLocale || "-"}</span></Field>
<div> <Field label="WebSocket URL">{device.websocket_url}</Field>
{coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"} <Field label="Has Assistant"><BoolBadge value={attr.hasAssistant} /></Field>
{coords && ( <div className="col-span-2 md:col-span-3">
<a <Field label="Church Assistant URL">{device.churchAssistantURL}</Field>
href={`https://www.google.com/maps?q=${coords.lat},${coords.lng}`}
target="_blank"
rel="noopener noreferrer"
className="ml-2 px-2 py-0.5 text-xs rounded-md inline-block"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
onClick={(e) => e.stopPropagation()}
>
Open in Maps
</a>
)}
</div> </div>
</Field>
{locationName && (
<Field label="Nearest Place">{locationName}</Field>
)}
</dl> </dl>
{coords && (
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)", height: 200 }}>
<MapContainer center={[coords.lat, coords.lng]} zoom={13} style={{ height: "100%", width: "100%" }} scrollWheelZoom={false}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[coords.lat, coords.lng]} />
</MapContainer>
</div>
)}
</SectionCard> </SectionCard>
{/* Subscription */} {/* Subscription */}
@@ -389,88 +359,47 @@ export default function DeviceDetail() {
</dl> </dl>
</SectionCard> </SectionCard>
{/* Device Settings (combined) */} {/* Location */}
<SectionCard title="Device Settings"> <SectionCard title="Location">
{/* Subsection 1: Basic Attributes */} <div className={coords ? "grid grid-cols-1 md:grid-cols-2 gap-4" : ""}>
<Subsection title="Basic Attributes" isFirst> <dl className="space-y-4">
<Field label="Bell Guard"><BoolBadge value={attr.bellGuardOn} /></Field> <Field label="Location">{device.device_location}</Field>
<Field label="Warnings On"><BoolBadge value={attr.warningsOn} /></Field> <Field label="Coordinates">
<Field label="Bell Guard Safety"><BoolBadge value={attr.bellGuardSafetyOn} /></Field> <div>
</Subsection> {coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"}
{coords && (
{/* Subsection 2: Bell Settings */} <a
<Subsection title="Bell Settings"> href={`https://www.google.com/maps?q=${coords.lat},${coords.lng}`}
<Field label="Has Bells"><BoolBadge value={attr.hasBells} /></Field> target="_blank"
<Field label="Total Bells">{attr.totalBells}</Field> rel="noopener noreferrer"
<Field label="Bell Outputs">{attr.bellOutputs?.length > 0 ? attr.bellOutputs.join(", ") : "-"}</Field> className="ml-2 px-2 py-0.5 text-xs rounded-md inline-block"
<Field label="Hammer Timings">{attr.hammerTimings?.length > 0 ? attr.hammerTimings.join(", ") : "-"}</Field> style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
</Subsection> onClick={(e) => e.stopPropagation()}
>
{/* Subsection 3.1: Clock Settings */} Open in Maps
<Subsection title="Clock Settings"> </a>
<Field label="Has Clock"><BoolBadge value={attr.hasClock} /></Field>
<Field label="Odd Output">{clock.clockOutputs?.[0] ?? "-"}</Field>
<Field label="Even Output">{clock.clockOutputs?.[1] ?? "-"}</Field>
<Field label="Run Pulse">{clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"}</Field>
<Field label="Pause Pulse">{clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"}</Field>
<Field label="Alerts Status"><BoolBadge value={clock.ringAlertsMasterOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Alerts Type"><span className="capitalize">{clock.ringAlerts || "-"}</span></Field>
<Field label="Ring Intervals">{clock.ringIntervals}</Field>
<Field label="Hour Bell">{clock.hourAlertsBell}</Field>
<Field label="Half-Hour Bell">{clock.halfhourAlertsBell}</Field>
<Field label="Quarter Bell">{clock.quarterAlertsBell}</Field>
<Field label="Daytime Silence"><BoolBadge value={clock.isDaySilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
{clock.isDaySilenceOn && (
<Field label="Daytime Period">
{formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)}
</Field>
)} )}
<Field label="Nighttime Silence"><BoolBadge value={clock.isNightSilenceOn} yesLabel="ON" noLabel="OFF" /></Field> </div>
{clock.isNightSilenceOn && (
<Field label="Nighttime Period">
{formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)}
</Field> </Field>
{locationName && (
<Field label="Nearest Place">{locationName}</Field>
)} )}
</Subsection>
{/* Subsection 3.2: Backlight Settings */}
<Subsection title="Backlight Settings">
<Field label="Auto Backlight"><BoolBadge value={clock.isBacklightAutomationOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Backlight Output">{clock.backlightOutput}</Field>
<Field label="On Time">{formatTimestamp(clock.backlightTurnOnTime)}</Field>
<Field label="Off Time">{formatTimestamp(clock.backlightTurnOffTime)}</Field>
</Subsection>
{/* Subsection 4: Network */}
<Subsection title="Network">
<Field label="Hostname">{net.hostname}</Field>
<Field label="Has Static IP"><BoolBadge value={net.useStaticIP} /></Field>
</Subsection>
{/* Subsection 5: Logging */}
<Subsection title="Logging">
<Field label="Serial Log Level">{attr.serialLogLevel}</Field>
<Field label="SD Log Level">{attr.sdLogLevel}</Field>
<Field label="MQTT Log Level">{attr.mqttLogLevel ?? 0}</Field>
</Subsection>
</SectionCard>
</div>
{/* Right column */}
<div className="space-y-6">
{/* Misc */}
<SectionCard title="Misc">
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Device Locale"><span className="capitalize">{attr.deviceLocale || "-"}</span></Field>
<Field label="WebSocket URL">{device.websocket_url}</Field>
<Field label="Has Assistant"><BoolBadge value={attr.hasAssistant} /></Field>
<div className="col-span-2 md:col-span-3">
<Field label="Church Assistant URL">{device.churchAssistantURL}</Field>
</div>
</dl> </dl>
{coords && (
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)", minHeight: 300 }}>
<MapContainer center={[coords.lat, coords.lng]} zoom={13} style={{ height: "100%", width: "100%", minHeight: 300 }} scrollWheelZoom={false}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={[coords.lat, coords.lng]} />
</MapContainer>
</div>
)}
</div>
</SectionCard> </SectionCard>
{/* Warranty & Maintenance & Statistics */} {/* Warranty, Maintenance & Statistics */}
<SectionCard title="Warranty, Maintenance & Statistics"> <SectionCard title="Warranty, Maintenance & Statistics">
{/* Subsection 1: Warranty */} {/* Subsection 1: Warranty */}
<Subsection title="Warranty Information" isFirst> <Subsection title="Warranty Information" isFirst>
@@ -539,7 +468,7 @@ export default function DeviceDetail() {
</SectionCard> </SectionCard>
{/* Users */} {/* Users */}
<SectionCard title={`Users (${deviceUsers.length})`}> <SectionCard title={`App Users (${deviceUsers.length})`}>
{usersLoading ? ( {usersLoading ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users...</p> <p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users...</p>
) : deviceUsers.length === 0 ? ( ) : deviceUsers.length === 0 ? (
@@ -577,10 +506,77 @@ export default function DeviceDetail() {
)} )}
</SectionCard> </SectionCard>
{/* Device Settings — spans 2 columns on ultrawide */}
<div className="2xl:col-span-2">
<SectionCard title="Device Settings">
{/* Subsection 1: Basic Attributes */}
<Subsection title="Basic Attributes" isFirst>
<Field label="Bell Guard"><BoolBadge value={attr.bellGuardOn} /></Field>
<Field label="Warnings On"><BoolBadge value={attr.warningsOn} /></Field>
<Field label="Bell Guard Safety"><BoolBadge value={attr.bellGuardSafetyOn} /></Field>
</Subsection>
{/* Subsection 2: Bell Settings */}
<Subsection title="Bell Settings">
<Field label="Has Bells"><BoolBadge value={attr.hasBells} /></Field>
<Field label="Total Bells">{attr.totalBells}</Field>
<Field label="Bell Outputs">{attr.bellOutputs?.length > 0 ? attr.bellOutputs.join(", ") : "-"}</Field>
<Field label="Hammer Timings">{attr.hammerTimings?.length > 0 ? attr.hammerTimings.join(", ") : "-"}</Field>
</Subsection>
{/* Subsection 3.1: Clock Settings */}
<Subsection title="Clock Settings">
<Field label="Has Clock"><BoolBadge value={attr.hasClock} /></Field>
<Field label="Odd Output">{clock.clockOutputs?.[0] ?? "-"}</Field>
<Field label="Even Output">{clock.clockOutputs?.[1] ?? "-"}</Field>
<Field label="Run Pulse">{clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"}</Field>
<Field label="Pause Pulse">{clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"}</Field>
<Field label="Alerts Status"><BoolBadge value={clock.ringAlertsMasterOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Alerts Type"><span className="capitalize">{clock.ringAlerts || "-"}</span></Field>
<Field label="Ring Intervals">{clock.ringIntervals}</Field>
<Field label="Hour Bell">{clock.hourAlertsBell}</Field>
<Field label="Half-Hour Bell">{clock.halfhourAlertsBell}</Field>
<Field label="Quarter Bell">{clock.quarterAlertsBell}</Field>
<Field label="Daytime Silence"><BoolBadge value={clock.isDaySilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
{clock.isDaySilenceOn && (
<Field label="Daytime Period">
{formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)}
</Field>
)}
<Field label="Nighttime Silence"><BoolBadge value={clock.isNightSilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
{clock.isNightSilenceOn && (
<Field label="Nighttime Period">
{formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)}
</Field>
)}
</Subsection>
{/* Subsection 3.2: Backlight Settings */}
<Subsection title="Backlight Settings">
<Field label="Auto Backlight"><BoolBadge value={clock.isBacklightAutomationOn} yesLabel="ON" noLabel="OFF" /></Field>
<Field label="Backlight Output">{clock.backlightOutput}</Field>
<Field label="On Time">{formatTimestamp(clock.backlightTurnOnTime)}</Field>
<Field label="Off Time">{formatTimestamp(clock.backlightTurnOffTime)}</Field>
</Subsection>
{/* Subsection 4: Network */}
<Subsection title="Network">
<Field label="Hostname">{net.hostname}</Field>
<Field label="Has Static IP"><BoolBadge value={net.useStaticIP} /></Field>
</Subsection>
{/* Subsection 5: Logging */}
<Subsection title="Logging">
<Field label="Serial Log Level">{attr.serialLogLevel}</Field>
<Field label="SD Log Level">{attr.sdLogLevel}</Field>
<Field label="MQTT Log Level">{attr.mqttLogLevel ?? 0}</Field>
</Subsection>
</SectionCard>
</div>
{/* Equipment Notes */} {/* Equipment Notes */}
<NotesPanel deviceId={id} /> <NotesPanel deviceId={id} />
</div> </div>
</div>
<ConfirmDialog <ConfirmDialog
open={showDelete} open={showDelete}

View File

@@ -108,10 +108,11 @@ export default function DeviceList() {
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns); const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
const [showColumnPicker, setShowColumnPicker] = useState(false); const [showColumnPicker, setShowColumnPicker] = useState(false);
const [mqttStatusMap, setMqttStatusMap] = useState({});
const columnPickerRef = useRef(null); const columnPickerRef = useRef(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasRole("superadmin", "device_manager"); const canEdit = hasPermission("devices", "edit");
// Close column picker on outside click // Close column picker on outside click
useEffect(() => { useEffect(() => {
@@ -136,8 +137,20 @@ export default function DeviceList() {
if (onlineFilter === "false") params.set("online", "false"); if (onlineFilter === "false") params.set("online", "false");
if (tierFilter) params.set("tier", tierFilter); if (tierFilter) params.set("tier", tierFilter);
const qs = params.toString(); const qs = params.toString();
const data = await api.get(`/devices${qs ? `?${qs}` : ""}`); const [data, mqttData] = await Promise.all([
api.get(`/devices${qs ? `?${qs}` : ""}`),
api.get("/mqtt/status").catch(() => null),
]);
setDevices(data.devices); setDevices(data.devices);
// Build MQTT status lookup by device serial
if (mqttData?.devices) {
const map = {};
for (const s of mqttData.devices) {
map[s.device_serial] = s;
}
setMqttStatusMap(map);
}
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -182,14 +195,17 @@ export default function DeviceList() {
const stats = device.device_stats || {}; const stats = device.device_stats || {};
switch (key) { switch (key) {
case "status": case "status": {
const mqtt = mqttStatusMap[device.device_id];
const isOnline = mqtt ? mqtt.online : device.is_Online;
return ( return (
<span <span
className={`inline-block w-2.5 h-2.5 rounded-full ${device.is_Online ? "bg-green-500" : ""}`} className={`inline-block w-2.5 h-2.5 rounded-full ${isOnline ? "bg-green-500" : ""}`}
style={!device.is_Online ? { backgroundColor: "var(--border-primary)" } : undefined} style={!isOnline ? { backgroundColor: "var(--border-primary)" } : undefined}
title={device.is_Online ? "Online" : "Offline"} title={isOnline ? "Online" : "Offline"}
/> />
); );
}
case "name": case "name":
return ( return (
<span className="font-medium" style={{ color: "var(--text-heading)" }}> <span className="font-medium" style={{ color: "var(--text-heading)" }}>

View File

@@ -36,8 +36,8 @@ function Field({ label, children }) {
export default function NoteDetail() { export default function NoteDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasRole("superadmin", "device_manager"); const canEdit = hasPermission("equipment", "edit");
const [note, setNote] = useState(null); const [note, setNote] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);

View File

@@ -30,8 +30,8 @@ export default function NoteList() {
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
const [hoveredRow, setHoveredRow] = useState(null); const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasRole("superadmin", "device_manager"); const canEdit = hasPermission("equipment", "edit");
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const deviceIdFilter = searchParams.get("device_id") || ""; const deviceIdFilter = searchParams.get("device_id") || "";

View File

@@ -30,8 +30,8 @@ export default function NotesPanel({ deviceId, userId }) {
const [category, setCategory] = useState("general"); const [category, setCategory] = useState("general");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasRole("superadmin", "device_manager"); const canEdit = hasPermission("equipment", "edit");
const fetchNotes = async () => { const fetchNotes = async () => {
setLoading(true); setLoading(true);

View File

@@ -3,46 +3,52 @@ import { NavLink, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
const navItems = [ const navItems = [
{ to: "/", label: "Dashboard", roles: null }, { to: "/", label: "Dashboard", permission: null },
{ {
label: "Melodies", label: "Melodies",
roles: ["superadmin", "melody_editor", "viewer"], permission: "melodies",
children: [ children: [
{ to: "/melodies", label: "Editor" }, { to: "/melodies", label: "Editor" },
{ to: "/melodies/settings", label: "Settings" }, { to: "/melodies/settings", label: "Settings" },
], ],
}, },
{ to: "/devices", label: "Devices", roles: ["superadmin", "device_manager", "viewer"] }, { to: "/devices", label: "Devices", permission: "devices" },
{ to: "/users", label: "Users", roles: ["superadmin", "user_manager", "viewer"] }, { to: "/users", label: "App Users", permission: "app_users" },
{ {
label: "MQTT", label: "MQTT",
roles: ["superadmin", "device_manager", "viewer"], permission: "mqtt",
children: [ children: [
{ to: "/mqtt", label: "Dashboard" }, { to: "/mqtt", label: "Dashboard" },
{ to: "/mqtt/commands", label: "Commands" }, { to: "/mqtt/commands", label: "Commands" },
{ to: "/mqtt/logs", label: "Logs" }, { to: "/mqtt/logs", label: "Logs" },
], ],
}, },
{ to: "/equipment/notes", label: "Equipment Notes", roles: ["superadmin", "device_manager", "viewer"] }, { to: "/equipment/notes", label: "Equipment Notes", permission: "equipment" },
]; ];
const linkClass = (isActive) => const linkClass = (isActive, locked) =>
`block px-3 py-2 rounded-md text-sm transition-colors ${ `block px-3 py-2 rounded-md text-sm transition-colors ${
isActive locked
? "opacity-40 cursor-not-allowed"
: isActive
? "bg-[var(--accent)] text-[var(--bg-primary)] font-medium" ? "bg-[var(--accent)] text-[var(--bg-primary)] font-medium"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]" : "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`; }`;
export default function Sidebar() { export default function Sidebar() {
const { hasRole } = useAuth(); const { hasPermission, hasRole } = useAuth();
const location = useLocation(); const location = useLocation();
const visibleItems = navItems.filter( const canViewSection = (permission) => {
(item) => item.roles === null || hasRole(...item.roles) if (!permission) return true;
); return hasPermission(permission, "view");
};
// Settings visible only to sysadmin and admin
const canManageStaff = hasRole("sysadmin", "admin");
return ( return (
<aside className="w-56 min-h-screen p-4 border-r" style={{ backgroundColor: "var(--bg-sidebar)", borderColor: "var(--border-primary)" }}> <aside className="w-56 min-h-screen p-4 border-r flex flex-col" style={{ backgroundColor: "var(--bg-sidebar)", borderColor: "var(--border-primary)" }}>
<div className="mb-8 px-2"> <div className="mb-8 px-2">
<img <img
src="/logo-dark.png" src="/logo-dark.png"
@@ -50,32 +56,57 @@ export default function Sidebar() {
className="h-10 w-auto" className="h-10 w-auto"
/> />
</div> </div>
<nav className="space-y-1"> <nav className="space-y-1 flex-1">
{visibleItems.map((item) => {navItems.map((item) => {
item.children ? ( const hasAccess = canViewSection(item.permission);
return item.children ? (
<CollapsibleGroup <CollapsibleGroup
key={item.label} key={item.label}
label={item.label} label={item.label}
children={item.children} children={item.children}
currentPath={location.pathname} currentPath={location.pathname}
locked={!hasAccess}
/> />
) : ( ) : (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={hasAccess ? item.to : "#"}
end={item.to === "/"} end={item.to === "/"}
className={({ isActive }) => linkClass(isActive)} className={({ isActive }) => linkClass(isActive && hasAccess, !hasAccess)}
onClick={(e) => !hasAccess && e.preventDefault()}
> >
<span className="flex items-center gap-2">
{item.label} {item.label}
</NavLink> {!hasAccess && (
) <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)} )}
</span>
</NavLink>
);
})}
</nav> </nav>
{/* Settings section at the bottom */}
{canManageStaff && (
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<nav className="space-y-1">
<CollapsibleGroup
label="Settings"
children={[
{ to: "/settings/staff", label: "Staff" },
]}
currentPath={location.pathname}
/>
</nav>
</div>
)}
</aside> </aside>
); );
} }
function CollapsibleGroup({ label, children, currentPath }) { function CollapsibleGroup({ label, children, currentPath, locked = false }) {
const isChildActive = children.some( const isChildActive = children.some(
(child) => (child) =>
currentPath === child.to || currentPath === child.to ||
@@ -89,14 +120,24 @@ function CollapsibleGroup({ label, children, currentPath }) {
<div> <div>
<button <button
type="button" type="button"
onClick={() => setOpen(!shouldBeOpen)} onClick={() => !locked && setOpen(!shouldBeOpen)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${ className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
isChildActive locked
? "opacity-40 cursor-not-allowed"
: isChildActive
? "text-[var(--text-heading)]" ? "text-[var(--text-heading)]"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]" : "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`} }`}
> >
<span>{label}</span> <span className="flex items-center gap-2">
{label}
{locked && (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)}
</span>
{!locked && (
<svg <svg
className={`w-4 h-4 transition-transform ${shouldBeOpen ? "rotate-90" : ""}`} className={`w-4 h-4 transition-transform ${shouldBeOpen ? "rotate-90" : ""}`}
fill="none" fill="none"
@@ -105,8 +146,9 @@ function CollapsibleGroup({ label, children, currentPath }) {
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg> </svg>
)}
</button> </button>
{shouldBeOpen && ( {!locked && shouldBeOpen && (
<div className="ml-3 mt-1 space-y-1"> <div className="ml-3 mt-1 space-y-1">
{children.map((child) => ( {children.map((child) => (
<NavLink <NavLink

View File

@@ -24,8 +24,8 @@ function Field({ label, children }) {
export default function MelodyDetail() { export default function MelodyDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasRole("superadmin", "melody_editor"); const canEdit = hasPermission("melodies", "edit");
const [melody, setMelody] = useState(null); const [melody, setMelody] = useState(null);
const [files, setFiles] = useState({}); const [files, setFiles] = useState({});

View File

@@ -63,8 +63,8 @@ export default function MelodyList() {
const [showColumnPicker, setShowColumnPicker] = useState(false); const [showColumnPicker, setShowColumnPicker] = useState(false);
const columnPickerRef = useRef(null); const columnPickerRef = useRef(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasRole("superadmin", "melody_editor"); const canEdit = hasPermission("melodies", "edit");
useEffect(() => { useEffect(() => {
api.get("/settings/melody").then((ms) => { api.get("/settings/melody").then((ms) => {

View File

@@ -0,0 +1,283 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import ConfirmDialog from "../components/ConfirmDialog";
const ROLE_COLORS = {
sysadmin: { bg: "var(--danger-bg)", text: "var(--danger-text)" },
admin: { bg: "#3b2a0a", text: "#f6ad55" },
editor: { bg: "var(--badge-blue-bg)", text: "var(--badge-blue-text)" },
user: { bg: "var(--bg-card-hover)", text: "var(--text-muted)" },
};
const SECTIONS = [
{ key: "melodies", label: "Melodies" },
{ key: "devices", label: "Devices" },
{ key: "app_users", label: "App Users" },
{ key: "equipment", label: "Equipment Notes" },
];
const ACTIONS = ["view", "add", "edit", "delete"];
function Field({ label, children }) {
return (
<div>
<dt className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>{label}</dt>
<dd className="mt-1 text-sm" style={{ color: "var(--text-primary)" }}>{children || "-"}</dd>
</div>
);
}
export default function StaffDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const [member, setMember] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showDelete, setShowDelete] = useState(false);
const [showResetPw, setShowResetPw] = useState(false);
const [newPassword, setNewPassword] = useState("");
const [pwError, setPwError] = useState("");
const [pwSuccess, setPwSuccess] = useState("");
useEffect(() => {
loadStaff();
}, [id]);
const loadStaff = async () => {
setLoading(true);
try {
const data = await api.get(`/staff/${id}`);
setMember(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
try {
await api.delete(`/staff/${id}`);
navigate("/settings/staff");
} catch (err) {
setError(err.message);
setShowDelete(false);
}
};
const handleResetPassword = async () => {
setPwError("");
setPwSuccess("");
if (newPassword.length < 6) {
setPwError("Password must be at least 6 characters");
return;
}
try {
await api.put(`/staff/${id}/password`, { new_password: newPassword });
setPwSuccess("Password updated successfully");
setNewPassword("");
setTimeout(() => {
setShowResetPw(false);
setPwSuccess("");
}, 2000);
} catch (err) {
setPwError(err.message);
}
};
if (loading) return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
if (error) return (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
);
if (!member) return null;
const canEdit = !(user?.role === "admin" && member.role === "sysadmin");
const canDelete = canEdit && member.id !== user?.sub;
const roleColors = ROLE_COLORS[member.role] || ROLE_COLORS.user;
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<button onClick={() => navigate("/settings/staff")} className="text-sm hover:underline mb-2 inline-block" style={{ color: "var(--accent)" }}>
&larr; Back to Staff
</button>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>{member.name}</h1>
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: roleColors.bg, color: roleColors.text }}>
{member.role}
</span>
<span
className="px-2 py-0.5 text-xs rounded-full"
style={
member.is_active
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
}
>
{member.is_active ? "Active" : "Inactive"}
</span>
</div>
</div>
{canEdit && (
<div className="flex gap-2">
<button onClick={() => navigate(`/settings/staff/${id}/edit`)} className="px-4 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>
Edit
</button>
<button onClick={() => setShowResetPw(true)} className="px-4 py-2 text-sm rounded-md transition-colors border" style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}>
Reset Password
</button>
{canDelete && (
<button onClick={() => setShowDelete(true)} className="px-4 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--danger)", color: "var(--text-white)" }}>
Delete
</button>
)}
</div>
)}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Account Info */}
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Account Information</h2>
<dl className="grid grid-cols-2 gap-4">
<Field label="Name">{member.name}</Field>
<Field label="Email">{member.email}</Field>
<Field label="Role">
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: roleColors.bg, color: roleColors.text }}>
{member.role}
</span>
</Field>
<Field label="Status">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={
member.is_active
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
}
>
{member.is_active ? "Active" : "Inactive"}
</span>
</Field>
<Field label="Document ID">
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{member.id}</span>
</Field>
</dl>
</section>
{/* Permissions */}
{(member.role === "editor" || member.role === "user") && member.permissions && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Section</th>
{ACTIONS.map((a) => (
<th key={a} className="px-3 py-2 text-center font-medium capitalize" style={{ color: "var(--text-secondary)" }}>{a}</th>
))}
</tr>
</thead>
<tbody>
{SECTIONS.map((sec) => {
const sp = member.permissions[sec.key] || {};
return (
<tr key={sec.key} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>{sec.label}</td>
{ACTIONS.map((a) => (
<td key={a} className="px-3 py-2 text-center">
{sp[a] ? (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Yes</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>No</span>
)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<div className="flex items-center gap-3">
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>MQTT Access:</span>
{member.permissions.mqtt ? (
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Enabled</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>Disabled</span>
)}
</div>
</div>
</section>
)}
{(member.role === "sysadmin" || member.role === "admin") && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
{member.role === "sysadmin"
? "SysAdmin has full access to all features and settings."
: "Admin has full access to all features, except managing SysAdmin accounts and system-level settings."}
</p>
</section>
)}
</div>
{/* Reset Password Dialog */}
{showResetPw && (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: "rgba(0,0,0,0.5)" }}>
<div className="rounded-lg border p-6 w-full max-w-md" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h3 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Reset Password</h3>
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>Enter a new password for {member.name}.</p>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="New password"
className="w-full px-3 py-2 rounded-md text-sm border mb-3"
style={{ backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-primary)" }}
/>
{pwError && <p className="text-xs mb-2" style={{ color: "var(--danger-text)" }}>{pwError}</p>}
{pwSuccess && <p className="text-xs mb-2" style={{ color: "var(--success-text)" }}>{pwSuccess}</p>}
<div className="flex gap-2 justify-end">
<button
onClick={() => { setShowResetPw(false); setNewPassword(""); setPwError(""); setPwSuccess(""); }}
className="px-4 py-2 text-sm rounded-md border cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
<button
onClick={handleResetPassword}
className="px-4 py-2 text-sm rounded-md cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Update Password
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
open={showDelete}
title="Delete Staff Member"
message={`Are you sure you want to delete "${member.name}" (${member.email})? This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => setShowDelete(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,320 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
const SECTIONS = [
{ key: "melodies", label: "Melodies" },
{ key: "devices", label: "Devices" },
{ key: "app_users", label: "App Users" },
{ key: "equipment", label: "Equipment Notes" },
];
const ACTIONS = ["view", "add", "edit", "delete"];
const DEFAULT_PERMS_EDITOR = {
melodies: { view: true, add: true, edit: true, delete: true },
devices: { view: true, add: true, edit: true, delete: true },
app_users: { view: true, add: true, edit: true, delete: true },
equipment: { view: true, add: true, edit: true, delete: true },
mqtt: true,
};
const DEFAULT_PERMS_USER = {
melodies: { view: true, add: false, edit: false, delete: false },
devices: { view: true, add: false, edit: false, delete: false },
app_users: { view: true, add: false, edit: false, delete: false },
equipment: { view: true, add: false, edit: false, delete: false },
mqtt: false,
};
export default function StaffForm() {
const { id } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const isEdit = !!id;
const [form, setForm] = useState({
name: "",
email: "",
password: "",
role: "user",
is_active: true,
});
const [permissions, setPermissions] = useState({ ...DEFAULT_PERMS_USER });
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (isEdit) {
setLoading(true);
api.get(`/staff/${id}`)
.then((data) => {
setForm({
name: data.name,
email: data.email,
password: "",
role: data.role,
is_active: data.is_active,
});
if (data.permissions) {
setPermissions(data.permissions);
} else if (data.role === "editor") {
setPermissions({ ...DEFAULT_PERMS_EDITOR });
} else if (data.role === "user") {
setPermissions({ ...DEFAULT_PERMS_USER });
}
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}
}, [id]);
const handleRoleChange = (newRole) => {
setForm((f) => ({ ...f, role: newRole }));
if (newRole === "editor") {
setPermissions({ ...DEFAULT_PERMS_EDITOR });
} else if (newRole === "user") {
setPermissions({ ...DEFAULT_PERMS_USER });
}
};
const togglePermission = (section, action) => {
setPermissions((prev) => ({
...prev,
[section]: {
...prev[section],
[action]: !prev[section][action],
},
}));
};
const toggleMqtt = () => {
setPermissions((prev) => ({ ...prev, mqtt: !prev.mqtt }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setSaving(true);
try {
const body = {
name: form.name,
email: form.email,
role: form.role,
};
if (isEdit) {
body.is_active = form.is_active;
if (form.role === "editor" || form.role === "user") {
body.permissions = permissions;
} else {
body.permissions = null;
}
await api.put(`/staff/${id}`, body);
navigate(`/settings/staff/${id}`);
} else {
body.password = form.password;
if (form.role === "editor" || form.role === "user") {
body.permissions = permissions;
}
const result = await api.post("/staff", body);
navigate(`/settings/staff/${result.id}`);
}
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
if (loading) return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
const roleOptions = user?.role === "sysadmin"
? ["sysadmin", "admin", "editor", "user"]
: ["editor", "user"]; // Admin can only create editor/user
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
const inputStyle = { backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-primary)" };
return (
<div>
<div className="mb-6">
<button onClick={() => navigate(isEdit ? `/settings/staff/${id}` : "/settings/staff")} className="text-sm hover:underline mb-2 inline-block" style={{ color: "var(--accent)" }}>
&larr; {isEdit ? "Back to Staff Member" : "Back to Staff"}
</button>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>{isEdit ? "Edit Staff Member" : "Add Staff Member"}</h1>
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6 max-w-4xl">
{/* Basic Info */}
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Account Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Name</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
className={inputClass}
style={inputStyle}
/>
</div>
<div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
required
className={inputClass}
style={inputStyle}
/>
</div>
{!isEdit && (
<div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Password</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
required
minLength={6}
className={inputClass}
style={inputStyle}
placeholder="Min 6 characters"
/>
</div>
)}
<div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Role</label>
<select
value={form.role}
onChange={(e) => handleRoleChange(e.target.value)}
className={`${inputClass} cursor-pointer`}
style={inputStyle}
>
{roleOptions.map((r) => (
<option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>
))}
</select>
</div>
{isEdit && (
<div>
<label className="block text-xs font-medium mb-1 uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</label>
<select
value={form.is_active ? "active" : "inactive"}
onChange={(e) => setForm((f) => ({ ...f, is_active: e.target.value === "active" }))}
className={`${inputClass} cursor-pointer`}
style={inputStyle}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
)}
</div>
</section>
{/* Permissions Matrix - only for editor/user */}
{(form.role === "editor" || form.role === "user") && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>
Configure which sections and actions this staff member can access.
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Section</th>
{ACTIONS.map((a) => (
<th key={a} className="px-3 py-2 text-center font-medium capitalize" style={{ color: "var(--text-secondary)" }}>{a}</th>
))}
</tr>
</thead>
<tbody>
{SECTIONS.map((sec) => {
const sp = permissions[sec.key] || {};
return (
<tr key={sec.key} style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<td className="px-3 py-3 font-medium" style={{ color: "var(--text-primary)" }}>{sec.label}</td>
{ACTIONS.map((a) => (
<td key={a} className="px-3 py-3 text-center">
<input
type="checkbox"
checked={!!sp[a]}
onChange={() => togglePermission(sec.key, a)}
className="h-4 w-4 rounded cursor-pointer"
/>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!!permissions.mqtt}
onChange={toggleMqtt}
className="h-4 w-4 rounded cursor-pointer"
/>
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>MQTT Access</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
(Dashboard, Commands, Logs, WebSocket)
</span>
</label>
</div>
</section>
)}
{(form.role === "sysadmin" || form.role === "admin") && (
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Permissions</h2>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
{form.role === "sysadmin"
? "SysAdmin has full access to all features and settings. No permission customization needed."
: "Admin has full access to all features, except managing SysAdmin accounts and system-level settings. No permission customization needed."}
</p>
</section>
)}
{/* Submit */}
<div className="flex gap-3">
<button
type="submit"
disabled={saving}
className="px-6 py-2 text-sm rounded-md transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)", opacity: saving ? 0.6 : 1 }}
>
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create Staff Member"}
</button>
<button
type="button"
onClick={() => navigate(isEdit ? `/settings/staff/${id}` : "/settings/staff")}
className="px-6 py-2 text-sm rounded-md border transition-colors cursor-pointer"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,209 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import SearchBar from "../components/SearchBar";
import ConfirmDialog from "../components/ConfirmDialog";
const ROLE_OPTIONS = ["", "sysadmin", "admin", "editor", "user"];
const ROLE_COLORS = {
sysadmin: { bg: "var(--danger-bg)", text: "var(--danger-text)" },
admin: { bg: "#3b2a0a", text: "#f6ad55" },
editor: { bg: "var(--badge-blue-bg)", text: "var(--badge-blue-text)" },
user: { bg: "var(--bg-card-hover)", text: "var(--text-muted)" },
};
function RoleBadge({ role }) {
const colors = ROLE_COLORS[role] || ROLE_COLORS.user;
return (
<span
className="px-2 py-0.5 text-xs rounded-full capitalize"
style={{ backgroundColor: colors.bg, color: colors.text }}
>
{role}
</span>
);
}
export default function StaffList() {
const [staff, setStaff] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null);
const navigate = useNavigate();
const { user } = useAuth();
const fetchStaff = async () => {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (roleFilter) params.set("role", roleFilter);
const qs = params.toString();
const data = await api.get(`/staff${qs ? `?${qs}` : ""}`);
setStaff(data.staff);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStaff();
}, [search, roleFilter]);
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await api.delete(`/staff/${deleteTarget.id}`);
setDeleteTarget(null);
fetchStaff();
} catch (err) {
setError(err.message);
setDeleteTarget(null);
}
};
const canDeleteStaff = (staffMember) => {
if (staffMember.id === user?.sub) return false;
if (user?.role === "admin" && staffMember.role === "sysadmin") return false;
return true;
};
const canEditStaff = (staffMember) => {
if (user?.role === "admin" && staffMember.role === "sysadmin") return false;
return true;
};
const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border";
const selectStyle = {
backgroundColor: "var(--bg-card)",
color: "var(--text-primary)",
borderColor: "var(--border-primary)",
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Staff</h1>
<button
onClick={() => navigate("/settings/staff/new")}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-colors cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Add Staff
</button>
</div>
<div className="mb-4 space-y-3">
<SearchBar onSearch={setSearch} placeholder="Search by name or email..." />
<div className="flex flex-wrap gap-3 items-center">
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)} className={selectClass} style={selectStyle}>
<option value="">All Roles</option>
{ROLE_OPTIONS.filter(Boolean).map((r) => (
<option key={r} value={r}>{r.charAt(0).toUpperCase() + r.slice(1)}</option>
))}
</select>
<span className="text-sm" style={{ color: "var(--text-muted)" }}>
{staff.length} {staff.length === 1 ? "member" : "members"}
</span>
</div>
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : staff.length === 0 ? (
<div className="rounded-lg p-8 text-center text-sm border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}>
No staff members found.
</div>
) : (
<div className="rounded-lg overflow-hidden border" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Email</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Role</th>
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-secondary)" }}>Status</th>
<th className="px-4 py-3 text-left font-medium w-24" style={{ color: "var(--text-secondary)" }} />
</tr>
</thead>
<tbody>
{staff.map((member) => (
<tr
key={member.id}
onClick={() => navigate(`/settings/staff/${member.id}`)}
className="cursor-pointer transition-colors"
style={{ borderBottom: "1px solid var(--border-secondary)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--bg-card-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "transparent")}
>
<td className="px-4 py-3">
<span className="font-medium" style={{ color: "var(--text-heading)" }}>{member.name}</span>
</td>
<td className="px-4 py-3" style={{ color: "var(--text-primary)" }}>{member.email}</td>
<td className="px-4 py-3"><RoleBadge role={member.role} /></td>
<td className="px-4 py-3">
<span
className="px-2 py-0.5 text-xs rounded-full"
style={
member.is_active
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }
}
>
{member.is_active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
{canEditStaff(member) && (
<button
onClick={() => navigate(`/settings/staff/${member.id}/edit`)}
className="hover:opacity-80 text-xs cursor-pointer"
style={{ color: "var(--text-link)" }}
>
Edit
</button>
)}
{canDeleteStaff(member) && (
<button
onClick={() => setDeleteTarget(member)}
className="hover:opacity-80 text-xs cursor-pointer"
style={{ color: "var(--danger)" }}
>
Delete
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<ConfirmDialog
open={!!deleteTarget}
title="Delete Staff Member"
message={`Are you sure you want to delete "${deleteTarget?.name}" (${deleteTarget?.email})? This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}

View File

@@ -24,8 +24,8 @@ function Field({ label, children }) {
export default function UserDetail() { export default function UserDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasRole("superadmin", "user_manager"); const canEdit = hasPermission("app_users", "edit");
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [devices, setDevices] = useState([]); const [devices, setDevices] = useState([]);
@@ -165,7 +165,7 @@ export default function UserDetail() {
className="text-sm hover:underline mb-2 inline-block" className="text-sm hover:underline mb-2 inline-block"
style={{ color: "var(--accent)" }} style={{ color: "var(--accent)" }}
> >
&larr; Back to Users &larr; Back to App Users
</button> </button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 <h1

View File

@@ -17,8 +17,8 @@ export default function UserList() {
const [deleteTarget, setDeleteTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null);
const [hoveredRow, setHoveredRow] = useState(null); const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const { hasRole } = useAuth(); const { hasPermission } = useAuth();
const canEdit = hasRole("superadmin", "user_manager"); const canEdit = hasPermission("app_users", "edit");
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true); setLoading(true);
@@ -57,7 +57,7 @@ export default function UserList() {
return ( return (
<div> <div>
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Users</h1> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>App Users</h1>
{canEdit && ( {canEdit && (
<button <button
onClick={() => navigate("/users/new")} onClick={() => navigate("/users/new")}