Added Roles and Permissions. Some minor UI fixes

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

103
backend/migrate_roles.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

@@ -18,6 +18,9 @@ import LogViewer from "./mqtt/LogViewer";
import NoteList from "./equipment/NoteList";
import NoteDetail from "./equipment/NoteDetail";
import NoteForm from "./equipment/NoteForm";
import StaffList from "./settings/StaffList";
import StaffDetail from "./settings/StaffDetail";
import StaffForm from "./settings/StaffForm";
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
@@ -37,6 +40,52 @@ function ProtectedRoute({ children }) {
return children;
}
function PermissionGate({ section, action = "view", children }) {
const { hasPermission } = useAuth();
if (!hasPermission(section, action)) {
return (
<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() {
const { user } = useAuth();
return (
@@ -62,26 +111,43 @@ export default function App() {
}
>
<Route index element={<DashboardPage />} />
<Route path="melodies" element={<MelodyList />} />
<Route path="melodies/settings" element={<MelodySettings />} />
<Route path="melodies/new" element={<MelodyForm />} />
<Route path="melodies/:id" element={<MelodyDetail />} />
<Route path="melodies/:id/edit" element={<MelodyForm />} />
<Route path="devices" element={<DeviceList />} />
<Route path="devices/new" element={<DeviceForm />} />
<Route path="devices/:id" element={<DeviceDetail />} />
<Route path="devices/:id/edit" element={<DeviceForm />} />
<Route path="users" element={<UserList />} />
<Route path="users/new" element={<UserForm />} />
<Route path="users/:id" element={<UserDetail />} />
<Route path="users/:id/edit" element={<UserForm />} />
<Route path="mqtt" element={<MqttDashboard />} />
<Route path="mqtt/commands" element={<CommandPanel />} />
<Route path="mqtt/logs" element={<LogViewer />} />
<Route path="equipment/notes" element={<NoteList />} />
<Route path="equipment/notes/new" element={<NoteForm />} />
<Route path="equipment/notes/:id" element={<NoteDetail />} />
<Route path="equipment/notes/:id/edit" element={<NoteForm />} />
{/* Melodies */}
<Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></PermissionGate>} />
<Route path="melodies/settings" element={<PermissionGate section="melodies"><MelodySettings /></PermissionGate>} />
<Route path="melodies/new" element={<PermissionGate section="melodies" action="add"><MelodyForm /></PermissionGate>} />
<Route path="melodies/:id" element={<PermissionGate section="melodies"><MelodyDetail /></PermissionGate>} />
<Route path="melodies/:id/edit" element={<PermissionGate section="melodies" action="edit"><MelodyForm /></PermissionGate>} />
{/* Devices */}
<Route path="devices" element={<PermissionGate section="devices"><DeviceList /></PermissionGate>} />
<Route path="devices/new" element={<PermissionGate section="devices" action="add"><DeviceForm /></PermissionGate>} />
<Route path="devices/:id" element={<PermissionGate section="devices"><DeviceDetail /></PermissionGate>} />
<Route path="devices/:id/edit" element={<PermissionGate section="devices" action="edit"><DeviceForm /></PermissionGate>} />
{/* App Users */}
<Route path="users" element={<PermissionGate section="app_users"><UserList /></PermissionGate>} />
<Route path="users/new" element={<PermissionGate section="app_users" action="add"><UserForm /></PermissionGate>} />
<Route path="users/:id" element={<PermissionGate section="app_users"><UserDetail /></PermissionGate>} />
<Route path="users/:id/edit" element={<PermissionGate section="app_users" action="edit"><UserForm /></PermissionGate>} />
{/* 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>
</Routes>

View File

@@ -36,9 +36,26 @@ export function AuthProvider({ children }) {
const login = async (email, password) => {
const data = await api.post("/auth/login", { email, password });
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));
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;
};
@@ -50,12 +67,30 @@ export function AuthProvider({ children }) {
const hasRole = (...roles) => {
if (!user) return false;
if (user.role === "superadmin") return true;
if (user.role === "sysadmin") return true;
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 (
<AuthContext.Provider value={{ user, login, logout, loading, hasRole }}>
<AuthContext.Provider value={{ user, login, logout, loading, hasRole, hasPermission }}>
{children}
</AuthContext.Provider>
);

View File

@@ -165,8 +165,8 @@ function formatCoordinates(coords) {
export default function DeviceDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { hasRole } = useAuth();
const canEdit = hasRole("superadmin", "device_manager");
const { hasPermission } = useAuth();
const canEdit = hasPermission("devices", "edit");
const [device, setDevice] = useState(null);
const [loading, setLoading] = useState(true);
@@ -298,35 +298,71 @@ export default function DeviceDetail() {
)}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left column */}
<div className="space-y-6">
{/* Basic Information */}
<SectionCard title="Basic Information">
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Serial Number">
<span className="font-mono">{device.device_id}</span>
</Field>
<Field label="Status">
<BoolBadge value={isOnline} yesLabel="Online" noLabel="Offline" />
{mqttStatus && (
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}>
(MQTT {mqttStatus.seconds_since_heartbeat}s ago)
</span>
)}
</Field>
<Field label="Events On">
<BoolBadge value={device.events_on} />
</Field>
<Field label="Document ID">
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.id}</span>
</Field>
</dl>
</SectionCard>
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-6">
{/* Basic Information */}
<SectionCard title="Basic Information">
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Serial Number">
<span className="font-mono">{device.device_id}</span>
</Field>
<Field label="Status">
<BoolBadge value={isOnline} yesLabel="Online" noLabel="Offline" />
{mqttStatus && (
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}>
(MQTT {mqttStatus.seconds_since_heartbeat}s ago)
</span>
)}
</Field>
<Field label="Document ID">
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.id}</span>
</Field>
</dl>
</SectionCard>
{/* Location */}
<SectionCard title="Location">
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* Misc */}
<SectionCard title="Misc">
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Automated Events"><BoolBadge value={device.events_on} yesLabel="ON" noLabel="OFF" /></Field>
<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>
</SectionCard>
{/* Subscription */}
<SectionCard title="Subscription">
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Tier">
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{sub.subscrTier}
</span>
</Field>
<Field label="Start Date">{formatDate(sub.subscrStart)}</Field>
<Field label="Duration">{sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"}</Field>
<Field label="Expiration Date">{subscrEnd ? formatDateNice(subscrEnd) : "-"}</Field>
<Field label="Time Left">
{subscrDaysLeft === null ? "-" : subscrDaysLeft <= 0 ? (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
Subscription Expired
</span>
) : (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
{subscrDaysLeft} days left
</span>
)}
</Field>
<Field label="Max Users">{sub.maxUsers}</Field>
<Field label="Max Outputs">{sub.maxOutputs}</Field>
</dl>
</SectionCard>
{/* Location */}
<SectionCard title="Location">
<div className={coords ? "grid grid-cols-1 md:grid-cols-2 gap-4" : ""}>
<dl className="space-y-4">
<Field label="Location">{device.device_location}</Field>
<Field label="Coordinates">
<div>
@@ -350,8 +386,8 @@ export default function DeviceDetail() {
)}
</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}>
<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"
@@ -360,36 +396,118 @@ export default function DeviceDetail() {
</MapContainer>
</div>
)}
</SectionCard>
</div>
</SectionCard>
{/* Subscription */}
<SectionCard title="Subscription">
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Tier">
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{sub.subscrTier}
</span>
</Field>
<Field label="Start Date">{formatDate(sub.subscrStart)}</Field>
<Field label="Duration">{sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"}</Field>
<Field label="Expiration Date">{subscrEnd ? formatDateNice(subscrEnd) : "-"}</Field>
<Field label="Time Left">
{subscrDaysLeft === null ? "-" : subscrDaysLeft <= 0 ? (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
Subscription Expired
</span>
{/* Warranty, Maintenance & Statistics */}
<SectionCard title="Warranty, Maintenance & Statistics">
{/* Subsection 1: Warranty */}
<Subsection title="Warranty Information" isFirst>
<Field label="Warranty Status">
{warrantyDaysLeft !== null ? (
warrantyDaysLeft > 0 ? (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Active</span>
) : (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
{subscrDaysLeft} days left
</span>
)}
</Field>
<Field label="Max Users">{sub.maxUsers}</Field>
<Field label="Max Outputs">{sub.maxOutputs}</Field>
</dl>
</SectionCard>
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>Expired</span>
)
) : (
<BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />
)}
</Field>
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
<Field label="Warranty Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
<Field label="Expiration Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
<Field label="Remaining">
{warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? (
<span style={{ color: "var(--danger-text)" }}>Expired</span>
) : (
`${warrantyDaysLeft} days`
)}
</Field>
</Subsection>
{/* Device Settings (combined) */}
{/* Subsection 2: Maintenance */}
<Subsection title="Maintenance">
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
<Field label="Next Scheduled">
{nextMaintenance ? (
<span>
{formatDateNice(nextMaintenance)}
{maintenanceDaysLeft !== null && (
<span className="ml-1 text-xs" style={{ color: maintenanceDaysLeft <= 0 ? "var(--danger-text)" : "var(--text-muted)" }}>
({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})
</span>
)}
</span>
) : "-"}
</Field>
</Subsection>
{/* Subsection 3: Statistics */}
<Subsection title="Statistics">
<Field label="Total Playbacks">{stats.totalPlaybacks}</Field>
<Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field>
<Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field>
<Field label="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
{stats.perBellStrikes?.length > 0 && (
<div className="col-span-2 md:col-span-3">
<Field label="Per Bell Strikes">
<div className="flex flex-wrap gap-2 mt-1">
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
<span key={i} className="px-2 py-1 text-xs rounded-md border" style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
Bell {i + 1}: {count}
</span>
))}
</div>
</Field>
</div>
)}
</Subsection>
</SectionCard>
{/* Users */}
<SectionCard title={`App Users (${deviceUsers.length})`}>
{usersLoading ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users...</p>
) : deviceUsers.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned to this device.</p>
) : (
<div className="space-y-2">
{deviceUsers.map((user, i) => (
<div
key={user.user_id || i}
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
onClick={() => user.user_id && navigate(`/users/${user.user_id}`)}
>
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
{user.display_name || user.email || "Unknown User"}
</p>
{user.email && user.display_name && (
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{user.email}</p>
)}
{user.user_id && (
<p className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{user.user_id}</p>
)}
</div>
{user.role && (
<span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0 ml-2" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{user.role}
</span>
)}
</div>
</div>
))}
</div>
)}
</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>
@@ -456,130 +574,8 @@ export default function DeviceDetail() {
</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>
</SectionCard>
{/* Warranty & Maintenance & Statistics */}
<SectionCard title="Warranty, Maintenance & Statistics">
{/* Subsection 1: Warranty */}
<Subsection title="Warranty Information" isFirst>
<Field label="Warranty Status">
{warrantyDaysLeft !== null ? (
warrantyDaysLeft > 0 ? (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Active</span>
) : (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>Expired</span>
)
) : (
<BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />
)}
</Field>
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
<Field label="Warranty Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
<Field label="Expiration Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
<Field label="Remaining">
{warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? (
<span style={{ color: "var(--danger-text)" }}>Expired</span>
) : (
`${warrantyDaysLeft} days`
)}
</Field>
</Subsection>
{/* Subsection 2: Maintenance */}
<Subsection title="Maintenance">
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
<Field label="Next Scheduled">
{nextMaintenance ? (
<span>
{formatDateNice(nextMaintenance)}
{maintenanceDaysLeft !== null && (
<span className="ml-1 text-xs" style={{ color: maintenanceDaysLeft <= 0 ? "var(--danger-text)" : "var(--text-muted)" }}>
({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})
</span>
)}
</span>
) : "-"}
</Field>
</Subsection>
{/* Subsection 3: Statistics */}
<Subsection title="Statistics">
<Field label="Total Playbacks">{stats.totalPlaybacks}</Field>
<Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field>
<Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field>
<Field label="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
{stats.perBellStrikes?.length > 0 && (
<div className="col-span-2 md:col-span-3">
<Field label="Per Bell Strikes">
<div className="flex flex-wrap gap-2 mt-1">
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
<span key={i} className="px-2 py-1 text-xs rounded-md border" style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
Bell {i + 1}: {count}
</span>
))}
</div>
</Field>
</div>
)}
</Subsection>
</SectionCard>
{/* Users */}
<SectionCard title={`Users (${deviceUsers.length})`}>
{usersLoading ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users...</p>
) : deviceUsers.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned to this device.</p>
) : (
<div className="space-y-2">
{deviceUsers.map((user, i) => (
<div
key={user.user_id || i}
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
onClick={() => user.user_id && navigate(`/users/${user.user_id}`)}
>
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
{user.display_name || user.email || "Unknown User"}
</p>
{user.email && user.display_name && (
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{user.email}</p>
)}
{user.user_id && (
<p className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{user.user_id}</p>
)}
</div>
{user.role && (
<span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0 ml-2" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{user.role}
</span>
)}
</div>
</div>
))}
</div>
)}
</SectionCard>
{/* Equipment Notes */}
<NotesPanel deviceId={id} />
</div>
{/* Equipment Notes */}
<NotesPanel deviceId={id} />
</div>
<ConfirmDialog

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,46 +3,52 @@ import { NavLink, useLocation } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
const navItems = [
{ to: "/", label: "Dashboard", roles: null },
{ to: "/", label: "Dashboard", permission: null },
{
label: "Melodies",
roles: ["superadmin", "melody_editor", "viewer"],
permission: "melodies",
children: [
{ to: "/melodies", label: "Editor" },
{ to: "/melodies/settings", label: "Settings" },
],
},
{ to: "/devices", label: "Devices", roles: ["superadmin", "device_manager", "viewer"] },
{ to: "/users", label: "Users", roles: ["superadmin", "user_manager", "viewer"] },
{ to: "/devices", label: "Devices", permission: "devices" },
{ to: "/users", label: "App Users", permission: "app_users" },
{
label: "MQTT",
roles: ["superadmin", "device_manager", "viewer"],
permission: "mqtt",
children: [
{ to: "/mqtt", label: "Dashboard" },
{ to: "/mqtt/commands", label: "Commands" },
{ 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 ${
isActive
? "bg-[var(--accent)] text-[var(--bg-primary)] font-medium"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
locked
? "opacity-40 cursor-not-allowed"
: isActive
? "bg-[var(--accent)] text-[var(--bg-primary)] font-medium"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`;
export default function Sidebar() {
const { hasRole } = useAuth();
const { hasPermission, hasRole } = useAuth();
const location = useLocation();
const visibleItems = navItems.filter(
(item) => item.roles === null || hasRole(...item.roles)
);
const canViewSection = (permission) => {
if (!permission) return true;
return hasPermission(permission, "view");
};
// Settings visible only to sysadmin and admin
const canManageStaff = hasRole("sysadmin", "admin");
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">
<img
src="/logo-dark.png"
@@ -50,32 +56,57 @@ export default function Sidebar() {
className="h-10 w-auto"
/>
</div>
<nav className="space-y-1">
{visibleItems.map((item) =>
item.children ? (
<nav className="space-y-1 flex-1">
{navItems.map((item) => {
const hasAccess = canViewSection(item.permission);
return item.children ? (
<CollapsibleGroup
key={item.label}
label={item.label}
children={item.children}
currentPath={location.pathname}
locked={!hasAccess}
/>
) : (
<NavLink
key={item.to}
to={item.to}
to={hasAccess ? item.to : "#"}
end={item.to === "/"}
className={({ isActive }) => linkClass(isActive)}
className={({ isActive }) => linkClass(isActive && hasAccess, !hasAccess)}
onClick={(e) => !hasAccess && e.preventDefault()}
>
{item.label}
<span className="flex items-center gap-2">
{item.label}
{!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>
{/* 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>
);
}
function CollapsibleGroup({ label, children, currentPath }) {
function CollapsibleGroup({ label, children, currentPath, locked = false }) {
const isChildActive = children.some(
(child) =>
currentPath === child.to ||
@@ -89,24 +120,35 @@ function CollapsibleGroup({ label, children, currentPath }) {
<div>
<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 ${
isChildActive
? "text-[var(--text-heading)]"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
locked
? "opacity-40 cursor-not-allowed"
: isChildActive
? "text-[var(--text-heading)]"
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
}`}
>
<span>{label}</span>
<svg
className={`w-4 h-4 transition-transform ${shouldBeOpen ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<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
className={`w-4 h-4 transition-transform ${shouldBeOpen ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</button>
{shouldBeOpen && (
{!locked && shouldBeOpen && (
<div className="ml-3 mt-1 space-y-1">
{children.map((child) => (
<NavLink

View File

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

View File

@@ -63,8 +63,8 @@ export default function MelodyList() {
const [showColumnPicker, setShowColumnPicker] = useState(false);
const columnPickerRef = useRef(null);
const navigate = useNavigate();
const { hasRole } = useAuth();
const canEdit = hasRole("superadmin", "melody_editor");
const { hasPermission } = useAuth();
const canEdit = hasPermission("melodies", "edit");
useEffect(() => {
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() {
const { id } = useParams();
const navigate = useNavigate();
const { hasRole } = useAuth();
const canEdit = hasRole("superadmin", "user_manager");
const { hasPermission } = useAuth();
const canEdit = hasPermission("app_users", "edit");
const [user, setUser] = useState(null);
const [devices, setDevices] = useState([]);
@@ -165,7 +165,7 @@ export default function UserDetail() {
className="text-sm hover:underline mb-2 inline-block"
style={{ color: "var(--accent)" }}
>
&larr; Back to Users
&larr; Back to App Users
</button>
<div className="flex items-center gap-3">
<h1

View File

@@ -17,8 +17,8 @@ export default function UserList() {
const [deleteTarget, setDeleteTarget] = useState(null);
const [hoveredRow, setHoveredRow] = useState(null);
const navigate = useNavigate();
const { hasRole } = useAuth();
const canEdit = hasRole("superadmin", "user_manager");
const { hasPermission } = useAuth();
const canEdit = hasPermission("app_users", "edit");
const fetchUsers = async () => {
setLoading(true);
@@ -57,7 +57,7 @@ export default function UserList() {
return (
<div>
<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 && (
<button
onClick={() => navigate("/users/new")}