Added Roles and Permissions. Some minor UI fixes
This commit is contained in:
@@ -4,6 +4,7 @@ from jose import JWTError
|
||||
from auth.utils import decode_access_token
|
||||
from auth.models import TokenPayload, Role
|
||||
from shared.exceptions import AuthenticationError, AuthorizationError
|
||||
from shared.firebase import get_db
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
@@ -28,7 +29,7 @@ def require_roles(*allowed_roles: Role):
|
||||
async def role_checker(
|
||||
current_user: TokenPayload = Depends(get_current_user),
|
||||
) -> TokenPayload:
|
||||
if current_user.role == Role.superadmin:
|
||||
if current_user.role == Role.sysadmin:
|
||||
return current_user
|
||||
if current_user.role not in [r.value for r in allowed_roles]:
|
||||
raise AuthorizationError()
|
||||
@@ -36,12 +37,63 @@ def require_roles(*allowed_roles: Role):
|
||||
return role_checker
|
||||
|
||||
|
||||
async def _get_user_permissions(user: TokenPayload) -> dict:
|
||||
"""Fetch permissions from Firestore for the given user."""
|
||||
if user.role in (Role.sysadmin, Role.admin):
|
||||
return None # Full access
|
||||
db = get_db()
|
||||
if not db:
|
||||
raise AuthorizationError()
|
||||
doc = db.collection("admin_users").document(user.sub).get()
|
||||
if not doc.exists:
|
||||
raise AuthorizationError()
|
||||
data = doc.to_dict()
|
||||
return data.get("permissions")
|
||||
|
||||
|
||||
def require_permission(section: str, action: str):
|
||||
"""Check granular permission for a section and action.
|
||||
section: 'melodies', 'devices', 'app_users', 'equipment', 'mqtt'
|
||||
action: 'view', 'add', 'edit', 'delete' (or ignored for mqtt)
|
||||
"""
|
||||
async def permission_checker(
|
||||
current_user: TokenPayload = Depends(get_current_user),
|
||||
) -> TokenPayload:
|
||||
# sysadmin and admin have full access
|
||||
if current_user.role in (Role.sysadmin, Role.admin):
|
||||
return current_user
|
||||
|
||||
permissions = await _get_user_permissions(current_user)
|
||||
if not permissions:
|
||||
raise AuthorizationError()
|
||||
|
||||
if section == "mqtt":
|
||||
if not permissions.get("mqtt", False):
|
||||
raise AuthorizationError()
|
||||
return current_user
|
||||
|
||||
section_perms = permissions.get(section)
|
||||
if not section_perms:
|
||||
raise AuthorizationError()
|
||||
|
||||
if isinstance(section_perms, dict):
|
||||
if not section_perms.get(action, False):
|
||||
raise AuthorizationError()
|
||||
else:
|
||||
raise AuthorizationError()
|
||||
|
||||
return current_user
|
||||
return permission_checker
|
||||
|
||||
|
||||
# Pre-built convenience dependencies
|
||||
require_superadmin = require_roles(Role.superadmin)
|
||||
require_melody_access = require_roles(Role.superadmin, Role.melody_editor)
|
||||
require_device_access = require_roles(Role.superadmin, Role.device_manager)
|
||||
require_user_access = require_roles(Role.superadmin, Role.user_manager)
|
||||
require_viewer = require_roles(
|
||||
Role.superadmin, Role.melody_editor, Role.device_manager,
|
||||
Role.user_manager, Role.viewer,
|
||||
require_sysadmin = require_roles(Role.sysadmin)
|
||||
require_admin_or_above = require_roles(Role.sysadmin, Role.admin)
|
||||
|
||||
# Staff management: only sysadmin and admin
|
||||
require_staff_management = require_roles(Role.sysadmin, Role.admin)
|
||||
|
||||
# Viewer-level: any authenticated user (actual permission check per-action)
|
||||
require_any_authenticated = require_roles(
|
||||
Role.sysadmin, Role.admin, Role.editor, Role.user,
|
||||
)
|
||||
|
||||
@@ -4,11 +4,49 @@ from enum import Enum
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
superadmin = "superadmin"
|
||||
melody_editor = "melody_editor"
|
||||
device_manager = "device_manager"
|
||||
user_manager = "user_manager"
|
||||
viewer = "viewer"
|
||||
sysadmin = "sysadmin"
|
||||
admin = "admin"
|
||||
editor = "editor"
|
||||
user = "user"
|
||||
|
||||
|
||||
class SectionPermissions(BaseModel):
|
||||
view: bool = False
|
||||
add: bool = False
|
||||
edit: bool = False
|
||||
delete: bool = False
|
||||
|
||||
|
||||
class StaffPermissions(BaseModel):
|
||||
melodies: SectionPermissions = SectionPermissions()
|
||||
devices: SectionPermissions = SectionPermissions()
|
||||
app_users: SectionPermissions = SectionPermissions()
|
||||
equipment: SectionPermissions = SectionPermissions()
|
||||
mqtt: bool = False
|
||||
|
||||
|
||||
# Default permissions per role
|
||||
def default_permissions_for_role(role: str) -> Optional[dict]:
|
||||
if role in ("sysadmin", "admin"):
|
||||
return None # Full access, permissions field not used
|
||||
full = {"view": True, "add": True, "edit": True, "delete": True}
|
||||
view_only = {"view": True, "add": False, "edit": False, "delete": False}
|
||||
if role == "editor":
|
||||
return {
|
||||
"melodies": full,
|
||||
"devices": full,
|
||||
"app_users": full,
|
||||
"equipment": full,
|
||||
"mqtt": True,
|
||||
}
|
||||
# user role - view only
|
||||
return {
|
||||
"melodies": view_only,
|
||||
"devices": view_only,
|
||||
"app_users": view_only,
|
||||
"equipment": view_only,
|
||||
"mqtt": False,
|
||||
}
|
||||
|
||||
|
||||
class AdminUserInDB(BaseModel):
|
||||
@@ -18,6 +56,7 @@ class AdminUserInDB(BaseModel):
|
||||
name: str
|
||||
role: Role
|
||||
is_active: bool = True
|
||||
permissions: Optional[StaffPermissions] = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -30,6 +69,7 @@ class TokenResponse(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
role: str
|
||||
name: str
|
||||
permissions: Optional[dict] = None
|
||||
|
||||
|
||||
class TokenPayload(BaseModel):
|
||||
|
||||
@@ -28,15 +28,32 @@ async def login(body: LoginRequest):
|
||||
if not verify_password(body.password, user_data["hashed_password"]):
|
||||
raise AuthenticationError("Invalid email or password")
|
||||
|
||||
role = user_data["role"]
|
||||
# Map legacy roles to new roles
|
||||
role_mapping = {
|
||||
"superadmin": "sysadmin",
|
||||
"melody_editor": "editor",
|
||||
"device_manager": "editor",
|
||||
"user_manager": "editor",
|
||||
"viewer": "user",
|
||||
}
|
||||
role = role_mapping.get(role, role)
|
||||
|
||||
token = create_access_token({
|
||||
"sub": doc.id,
|
||||
"email": user_data["email"],
|
||||
"role": user_data["role"],
|
||||
"role": role,
|
||||
"name": user_data["name"],
|
||||
})
|
||||
|
||||
# Get permissions for editor/user roles
|
||||
permissions = None
|
||||
if role in ("editor", "user"):
|
||||
permissions = user_data.get("permissions")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
role=user_data["role"],
|
||||
role=role,
|
||||
name=user_data["name"],
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import Optional
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_device_access, require_viewer
|
||||
from auth.dependencies import require_permission
|
||||
from devices.models import (
|
||||
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
|
||||
DeviceUsersResponse, DeviceUserInfo,
|
||||
@@ -16,7 +16,7 @@ async def list_devices(
|
||||
search: Optional[str] = Query(None),
|
||||
online: Optional[bool] = Query(None),
|
||||
tier: Optional[str] = Query(None),
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||
):
|
||||
devices = service.list_devices(
|
||||
search=search,
|
||||
@@ -29,7 +29,7 @@ async def list_devices(
|
||||
@router.get("/{device_id}", response_model=DeviceInDB)
|
||||
async def get_device(
|
||||
device_id: str,
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||
):
|
||||
return service.get_device(device_id)
|
||||
|
||||
@@ -37,7 +37,7 @@ async def get_device(
|
||||
@router.get("/{device_id}/users", response_model=DeviceUsersResponse)
|
||||
async def get_device_users(
|
||||
device_id: str,
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||
):
|
||||
users_data = service.get_device_users(device_id)
|
||||
users = [DeviceUserInfo(**u) for u in users_data]
|
||||
@@ -47,7 +47,7 @@ async def get_device_users(
|
||||
@router.post("", response_model=DeviceInDB, status_code=201)
|
||||
async def create_device(
|
||||
body: DeviceCreate,
|
||||
_user: TokenPayload = Depends(require_device_access),
|
||||
_user: TokenPayload = Depends(require_permission("devices", "add")),
|
||||
):
|
||||
return service.create_device(body)
|
||||
|
||||
@@ -56,7 +56,7 @@ async def create_device(
|
||||
async def update_device(
|
||||
device_id: str,
|
||||
body: DeviceUpdate,
|
||||
_user: TokenPayload = Depends(require_device_access),
|
||||
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||
):
|
||||
return service.update_device(device_id, body)
|
||||
|
||||
@@ -64,6 +64,6 @@ async def update_device(
|
||||
@router.delete("/{device_id}", status_code=204)
|
||||
async def delete_device(
|
||||
device_id: str,
|
||||
_user: TokenPayload = Depends(require_device_access),
|
||||
_user: TokenPayload = Depends(require_permission("devices", "delete")),
|
||||
):
|
||||
service.delete_device(device_id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import Optional
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_device_access, require_viewer
|
||||
from auth.dependencies import require_permission
|
||||
from equipment.models import (
|
||||
NoteCreate, NoteUpdate, NoteInDB, NoteListResponse,
|
||||
)
|
||||
@@ -16,7 +16,7 @@ async def list_notes(
|
||||
category: Optional[str] = Query(None),
|
||||
device_id: Optional[str] = Query(None),
|
||||
user_id: Optional[str] = Query(None),
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("equipment", "view")),
|
||||
):
|
||||
notes = service.list_notes(
|
||||
search=search, category=category,
|
||||
@@ -28,7 +28,7 @@ async def list_notes(
|
||||
@router.get("/{note_id}", response_model=NoteInDB)
|
||||
async def get_note(
|
||||
note_id: str,
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("equipment", "view")),
|
||||
):
|
||||
return service.get_note(note_id)
|
||||
|
||||
@@ -36,7 +36,7 @@ async def get_note(
|
||||
@router.post("", response_model=NoteInDB, status_code=201)
|
||||
async def create_note(
|
||||
body: NoteCreate,
|
||||
_user: TokenPayload = Depends(require_device_access),
|
||||
_user: TokenPayload = Depends(require_permission("equipment", "add")),
|
||||
):
|
||||
return service.create_note(body, created_by=_user.name or _user.email)
|
||||
|
||||
@@ -45,7 +45,7 @@ async def create_note(
|
||||
async def update_note(
|
||||
note_id: str,
|
||||
body: NoteUpdate,
|
||||
_user: TokenPayload = Depends(require_device_access),
|
||||
_user: TokenPayload = Depends(require_permission("equipment", "edit")),
|
||||
):
|
||||
return service.update_note(note_id, body)
|
||||
|
||||
@@ -53,6 +53,6 @@ async def update_note(
|
||||
@router.delete("/{note_id}", status_code=204)
|
||||
async def delete_note(
|
||||
note_id: str,
|
||||
_user: TokenPayload = Depends(require_device_access),
|
||||
_user: TokenPayload = Depends(require_permission("equipment", "delete")),
|
||||
):
|
||||
service.delete_note(note_id)
|
||||
|
||||
@@ -10,6 +10,7 @@ from settings.router import router as settings_router
|
||||
from users.router import router as users_router
|
||||
from mqtt.router import router as mqtt_router
|
||||
from equipment.router import router as equipment_router
|
||||
from staff.router import router as staff_router
|
||||
from mqtt.client import mqtt_manager
|
||||
from mqtt import database as mqtt_db
|
||||
|
||||
@@ -35,6 +36,7 @@ app.include_router(settings_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(mqtt_router)
|
||||
app.include_router(equipment_router)
|
||||
app.include_router(staff_router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException
|
||||
from typing import Optional
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_melody_access, require_viewer
|
||||
from auth.dependencies import require_permission
|
||||
from melodies.models import (
|
||||
MelodyCreate, MelodyUpdate, MelodyInDB, MelodyListResponse, MelodyInfo,
|
||||
)
|
||||
@@ -16,7 +16,7 @@ async def list_melodies(
|
||||
type: Optional[str] = Query(None),
|
||||
tone: Optional[str] = Query(None),
|
||||
total_notes: Optional[int] = Query(None),
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
||||
):
|
||||
melodies = service.list_melodies(
|
||||
search=search,
|
||||
@@ -30,7 +30,7 @@ async def list_melodies(
|
||||
@router.get("/{melody_id}", response_model=MelodyInDB)
|
||||
async def get_melody(
|
||||
melody_id: str,
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
||||
):
|
||||
return service.get_melody(melody_id)
|
||||
|
||||
@@ -38,7 +38,7 @@ async def get_melody(
|
||||
@router.post("", response_model=MelodyInDB, status_code=201)
|
||||
async def create_melody(
|
||||
body: MelodyCreate,
|
||||
_user: TokenPayload = Depends(require_melody_access),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "add")),
|
||||
):
|
||||
return service.create_melody(body)
|
||||
|
||||
@@ -47,7 +47,7 @@ async def create_melody(
|
||||
async def update_melody(
|
||||
melody_id: str,
|
||||
body: MelodyUpdate,
|
||||
_user: TokenPayload = Depends(require_melody_access),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||
):
|
||||
return service.update_melody(melody_id, body)
|
||||
|
||||
@@ -55,7 +55,7 @@ async def update_melody(
|
||||
@router.delete("/{melody_id}", status_code=204)
|
||||
async def delete_melody(
|
||||
melody_id: str,
|
||||
_user: TokenPayload = Depends(require_melody_access),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "delete")),
|
||||
):
|
||||
service.delete_melody(melody_id)
|
||||
|
||||
@@ -65,7 +65,7 @@ async def upload_file(
|
||||
melody_id: str,
|
||||
file_type: str,
|
||||
file: UploadFile = File(...),
|
||||
_user: TokenPayload = Depends(require_melody_access),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||
):
|
||||
"""Upload a binary or preview file. file_type must be 'binary' or 'preview'."""
|
||||
if file_type not in ("binary", "preview"):
|
||||
@@ -100,7 +100,7 @@ async def upload_file(
|
||||
async def delete_file(
|
||||
melody_id: str,
|
||||
file_type: str,
|
||||
_user: TokenPayload = Depends(require_melody_access),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||
):
|
||||
"""Delete a binary or preview file. file_type must be 'binary' or 'preview'."""
|
||||
if file_type not in ("binary", "preview"):
|
||||
@@ -113,7 +113,7 @@ async def delete_file(
|
||||
@router.get("/{melody_id}/files")
|
||||
async def get_files(
|
||||
melody_id: str,
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
||||
):
|
||||
"""Get storage file URLs for a melody."""
|
||||
service.get_melody(melody_id)
|
||||
|
||||
103
backend/migrate_roles.py
Normal file
103
backend/migrate_roles.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Migration script to update existing admin users from old roles to new roles.
|
||||
|
||||
Old roles -> New roles:
|
||||
superadmin -> sysadmin
|
||||
melody_editor -> editor (with melodies full access)
|
||||
device_manager -> editor (with devices + equipment full access)
|
||||
user_manager -> editor (with app_users full access)
|
||||
viewer -> user (view-only)
|
||||
|
||||
Usage:
|
||||
python migrate_roles.py
|
||||
python migrate_roles.py --dry-run (preview changes without applying)
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from shared.firebase import init_firebase, get_db
|
||||
|
||||
|
||||
ROLE_MAPPING = {
|
||||
"superadmin": "sysadmin",
|
||||
"melody_editor": "editor",
|
||||
"device_manager": "editor",
|
||||
"user_manager": "editor",
|
||||
"viewer": "user",
|
||||
}
|
||||
|
||||
FULL = {"view": True, "add": True, "edit": True, "delete": True}
|
||||
VIEW_ONLY = {"view": True, "add": False, "edit": False, "delete": False}
|
||||
|
||||
PERMISSION_MAPPING = {
|
||||
"melody_editor": {
|
||||
"melodies": FULL,
|
||||
"devices": VIEW_ONLY,
|
||||
"app_users": VIEW_ONLY,
|
||||
"equipment": VIEW_ONLY,
|
||||
"mqtt": False,
|
||||
},
|
||||
"device_manager": {
|
||||
"melodies": VIEW_ONLY,
|
||||
"devices": FULL,
|
||||
"app_users": VIEW_ONLY,
|
||||
"equipment": FULL,
|
||||
"mqtt": True,
|
||||
},
|
||||
"user_manager": {
|
||||
"melodies": VIEW_ONLY,
|
||||
"devices": VIEW_ONLY,
|
||||
"app_users": FULL,
|
||||
"equipment": VIEW_ONLY,
|
||||
"mqtt": False,
|
||||
},
|
||||
"viewer": {
|
||||
"melodies": VIEW_ONLY,
|
||||
"devices": VIEW_ONLY,
|
||||
"app_users": VIEW_ONLY,
|
||||
"equipment": VIEW_ONLY,
|
||||
"mqtt": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def migrate(dry_run=False):
|
||||
init_firebase()
|
||||
db = get_db()
|
||||
if not db:
|
||||
print("ERROR: Firebase initialization failed.")
|
||||
sys.exit(1)
|
||||
|
||||
docs = db.collection("admin_users").get()
|
||||
migrated = 0
|
||||
|
||||
for doc in docs:
|
||||
data = doc.to_dict()
|
||||
old_role = data.get("role", "")
|
||||
|
||||
if old_role in ROLE_MAPPING:
|
||||
new_role = ROLE_MAPPING[old_role]
|
||||
permissions = PERMISSION_MAPPING.get(old_role)
|
||||
|
||||
print(f" {data.get('email', '?'):30s} {old_role:20s} -> {new_role}")
|
||||
|
||||
if not dry_run:
|
||||
update = {"role": new_role}
|
||||
if permissions:
|
||||
update["permissions"] = permissions
|
||||
doc.reference.update(update)
|
||||
|
||||
migrated += 1
|
||||
else:
|
||||
print(f" {data.get('email', '?'):30s} {old_role:20s} (already migrated, skipping)")
|
||||
|
||||
action = "would be" if dry_run else "were"
|
||||
print(f"\n{migrated} user(s) {action} migrated.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Migrate admin user roles")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview changes without applying")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Migrating admin user roles...\n")
|
||||
migrate(dry_run=args.dry_run)
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||
from typing import Optional
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_device_access, require_viewer
|
||||
from auth.dependencies import require_permission
|
||||
from mqtt.models import (
|
||||
MqttCommandRequest, CommandSendResponse, MqttStatusResponse,
|
||||
DeviceMqttStatus, LogListResponse, HeartbeatListResponse,
|
||||
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/api/mqtt", tags=["mqtt"])
|
||||
|
||||
@router.get("/status", response_model=MqttStatusResponse)
|
||||
async def get_all_device_status(
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
heartbeats = await db.get_latest_heartbeats()
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -47,7 +47,7 @@ async def get_all_device_status(
|
||||
async def send_command(
|
||||
device_serial: str,
|
||||
body: MqttCommandRequest,
|
||||
_user: TokenPayload = Depends(require_device_access),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
command_id = await db.insert_command(
|
||||
device_serial=device_serial,
|
||||
@@ -84,7 +84,7 @@ async def get_device_logs(
|
||||
search: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
logs, total = await db.get_logs(
|
||||
device_serial, level=level, search=search,
|
||||
@@ -98,7 +98,7 @@ async def get_device_heartbeats(
|
||||
device_serial: str,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
heartbeats, total = await db.get_heartbeats(
|
||||
device_serial, limit=limit, offset=offset,
|
||||
@@ -111,7 +111,7 @@ async def get_device_commands(
|
||||
device_serial: str,
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||
):
|
||||
commands, total = await db.get_commands(
|
||||
device_serial, limit=limit, offset=offset,
|
||||
@@ -129,11 +129,27 @@ 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")
|
||||
|
||||
# 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")
|
||||
|
||||
@@ -34,15 +34,15 @@ def seed_superadmin(email: str, password: str, name: str):
|
||||
"email": email,
|
||||
"hashed_password": hash_password(password),
|
||||
"name": name,
|
||||
"role": "superadmin",
|
||||
"role": "sysadmin",
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
doc_ref = db.collection("admin_users").add(user_data)
|
||||
print(f"Superadmin created successfully!")
|
||||
print(f"SysAdmin created successfully!")
|
||||
print(f" Email: {email}")
|
||||
print(f" Name: {name}")
|
||||
print(f" Role: superadmin")
|
||||
print(f" Role: sysadmin")
|
||||
print(f" Doc ID: {doc_ref[1].id}")
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_melody_access, require_viewer
|
||||
from auth.dependencies import require_permission
|
||||
from settings.models import MelodySettings, MelodySettingsUpdate
|
||||
from settings import service
|
||||
|
||||
@@ -9,7 +9,7 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
@router.get("/melody", response_model=MelodySettings)
|
||||
async def get_melody_settings(
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
||||
):
|
||||
return service.get_melody_settings()
|
||||
|
||||
@@ -17,6 +17,6 @@ async def get_melody_settings(
|
||||
@router.put("/melody", response_model=MelodySettings)
|
||||
async def update_melody_settings(
|
||||
body: MelodySettingsUpdate,
|
||||
_user: TokenPayload = Depends(require_melody_access),
|
||||
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||
):
|
||||
return service.update_melody_settings(body)
|
||||
|
||||
0
backend/staff/__init__.py
Normal file
0
backend/staff/__init__.py
Normal file
37
backend/staff/models.py
Normal file
37
backend/staff/models.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from auth.models import StaffPermissions
|
||||
|
||||
|
||||
class StaffCreate(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
name: str
|
||||
role: str # sysadmin, admin, editor, user
|
||||
permissions: Optional[StaffPermissions] = None
|
||||
|
||||
|
||||
class StaffUpdate(BaseModel):
|
||||
email: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
permissions: Optional[StaffPermissions] = None
|
||||
|
||||
|
||||
class StaffPasswordUpdate(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class StaffResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
is_active: bool
|
||||
permissions: Optional[dict] = None
|
||||
|
||||
|
||||
class StaffListResponse(BaseModel):
|
||||
staff: list[StaffResponse]
|
||||
total: int
|
||||
82
backend/staff/router.py
Normal file
82
backend/staff/router.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from auth.dependencies import get_current_user, require_staff_management
|
||||
from auth.models import TokenPayload
|
||||
from staff import service
|
||||
from staff.models import (
|
||||
StaffCreate, StaffUpdate, StaffPasswordUpdate,
|
||||
StaffResponse, StaffListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/staff", tags=["staff"])
|
||||
|
||||
|
||||
@router.get("/me", response_model=StaffResponse)
|
||||
async def get_current_staff(current_user: TokenPayload = Depends(get_current_user)):
|
||||
return await service.get_staff_me(current_user.sub)
|
||||
|
||||
|
||||
@router.get("", response_model=StaffListResponse)
|
||||
async def list_staff(
|
||||
search: str = Query(None),
|
||||
role: str = Query(None),
|
||||
current_user: TokenPayload = Depends(require_staff_management),
|
||||
):
|
||||
return await service.list_staff(search=search, role_filter=role)
|
||||
|
||||
|
||||
@router.get("/{staff_id}", response_model=StaffResponse)
|
||||
async def get_staff(
|
||||
staff_id: str,
|
||||
current_user: TokenPayload = Depends(require_staff_management),
|
||||
):
|
||||
return await service.get_staff(staff_id)
|
||||
|
||||
|
||||
@router.post("", response_model=StaffResponse)
|
||||
async def create_staff(
|
||||
body: StaffCreate,
|
||||
current_user: TokenPayload = Depends(require_staff_management),
|
||||
):
|
||||
return await service.create_staff(
|
||||
data=body.model_dump(),
|
||||
current_user_role=current_user.role,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{staff_id}", response_model=StaffResponse)
|
||||
async def update_staff(
|
||||
staff_id: str,
|
||||
body: StaffUpdate,
|
||||
current_user: TokenPayload = Depends(require_staff_management),
|
||||
):
|
||||
return await service.update_staff(
|
||||
staff_id=staff_id,
|
||||
data=body.model_dump(exclude_unset=True),
|
||||
current_user_role=current_user.role,
|
||||
current_user_id=current_user.sub,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{staff_id}/password")
|
||||
async def update_staff_password(
|
||||
staff_id: str,
|
||||
body: StaffPasswordUpdate,
|
||||
current_user: TokenPayload = Depends(require_staff_management),
|
||||
):
|
||||
return await service.update_staff_password(
|
||||
staff_id=staff_id,
|
||||
new_password=body.new_password,
|
||||
current_user_role=current_user.role,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{staff_id}")
|
||||
async def delete_staff(
|
||||
staff_id: str,
|
||||
current_user: TokenPayload = Depends(require_staff_management),
|
||||
):
|
||||
return await service.delete_staff(
|
||||
staff_id=staff_id,
|
||||
current_user_role=current_user.role,
|
||||
current_user_id=current_user.sub,
|
||||
)
|
||||
178
backend/staff/service.py
Normal file
178
backend/staff/service.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from shared.firebase import get_db
|
||||
from auth.utils import hash_password
|
||||
from auth.models import default_permissions_for_role
|
||||
from shared.exceptions import NotFoundError, AuthorizationError
|
||||
import uuid
|
||||
|
||||
|
||||
VALID_ROLES = ("sysadmin", "admin", "editor", "user")
|
||||
|
||||
|
||||
def _staff_doc_to_response(doc_id: str, data: dict) -> dict:
|
||||
return {
|
||||
"id": doc_id,
|
||||
"email": data.get("email", ""),
|
||||
"name": data.get("name", ""),
|
||||
"role": data.get("role", ""),
|
||||
"is_active": data.get("is_active", True),
|
||||
"permissions": data.get("permissions"),
|
||||
}
|
||||
|
||||
|
||||
async def list_staff(search: str = None, role_filter: str = None) -> dict:
|
||||
db = get_db()
|
||||
ref = db.collection("admin_users")
|
||||
docs = ref.get()
|
||||
|
||||
staff = []
|
||||
for doc in docs:
|
||||
data = doc.to_dict()
|
||||
if search:
|
||||
s = search.lower()
|
||||
if s not in (data.get("name", "").lower()) and s not in (data.get("email", "").lower()):
|
||||
continue
|
||||
if role_filter:
|
||||
if data.get("role") != role_filter:
|
||||
continue
|
||||
staff.append(_staff_doc_to_response(doc.id, data))
|
||||
|
||||
return {"staff": staff, "total": len(staff)}
|
||||
|
||||
|
||||
async def get_staff(staff_id: str) -> dict:
|
||||
db = get_db()
|
||||
doc = db.collection("admin_users").document(staff_id).get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Staff member not found")
|
||||
return _staff_doc_to_response(doc.id, doc.to_dict())
|
||||
|
||||
|
||||
async def get_staff_me(user_sub: str) -> dict:
|
||||
db = get_db()
|
||||
doc = db.collection("admin_users").document(user_sub).get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Staff member not found")
|
||||
return _staff_doc_to_response(doc.id, doc.to_dict())
|
||||
|
||||
|
||||
async def create_staff(data: dict, current_user_role: str) -> dict:
|
||||
role = data.get("role", "user")
|
||||
if role not in VALID_ROLES:
|
||||
raise AuthorizationError(f"Invalid role: {role}")
|
||||
|
||||
# Admin cannot create sysadmin
|
||||
if current_user_role == "admin" and role == "sysadmin":
|
||||
raise AuthorizationError("Admin cannot create sysadmin accounts")
|
||||
|
||||
db = get_db()
|
||||
|
||||
# Check for duplicate email
|
||||
existing = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get()
|
||||
if existing:
|
||||
raise AuthorizationError("A staff member with this email already exists")
|
||||
|
||||
uid = str(uuid.uuid4())
|
||||
hashed = hash_password(data["password"])
|
||||
|
||||
# Set default permissions for editor/user if not provided
|
||||
permissions = data.get("permissions")
|
||||
if permissions is None and role in ("editor", "user"):
|
||||
permissions = default_permissions_for_role(role)
|
||||
|
||||
doc_data = {
|
||||
"uid": uid,
|
||||
"email": data["email"],
|
||||
"hashed_password": hashed,
|
||||
"name": data["name"],
|
||||
"role": role,
|
||||
"is_active": True,
|
||||
"permissions": permissions,
|
||||
}
|
||||
|
||||
doc_ref = db.collection("admin_users").document(uid)
|
||||
doc_ref.set(doc_data)
|
||||
|
||||
return _staff_doc_to_response(uid, doc_data)
|
||||
|
||||
|
||||
async def update_staff(staff_id: str, data: dict, current_user_role: str, current_user_id: str) -> dict:
|
||||
db = get_db()
|
||||
doc_ref = db.collection("admin_users").document(staff_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Staff member not found")
|
||||
|
||||
existing = doc.to_dict()
|
||||
|
||||
# Admin cannot edit sysadmin accounts
|
||||
if current_user_role == "admin" and existing.get("role") == "sysadmin":
|
||||
raise AuthorizationError("Admin cannot modify sysadmin accounts")
|
||||
|
||||
# Admin cannot promote to sysadmin
|
||||
if current_user_role == "admin" and data.get("role") == "sysadmin":
|
||||
raise AuthorizationError("Admin cannot promote to sysadmin")
|
||||
|
||||
update_data = {}
|
||||
if data.get("email") is not None:
|
||||
# Check for duplicate email
|
||||
others = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get()
|
||||
for other in others:
|
||||
if other.id != staff_id:
|
||||
raise AuthorizationError("A staff member with this email already exists")
|
||||
update_data["email"] = data["email"]
|
||||
if data.get("name") is not None:
|
||||
update_data["name"] = data["name"]
|
||||
if data.get("role") is not None:
|
||||
if data["role"] not in VALID_ROLES:
|
||||
raise AuthorizationError(f"Invalid role: {data['role']}")
|
||||
update_data["role"] = data["role"]
|
||||
if data.get("is_active") is not None:
|
||||
update_data["is_active"] = data["is_active"]
|
||||
if "permissions" in data:
|
||||
update_data["permissions"] = data["permissions"]
|
||||
|
||||
if update_data:
|
||||
doc_ref.update(update_data)
|
||||
|
||||
updated = {**existing, **update_data}
|
||||
return _staff_doc_to_response(staff_id, updated)
|
||||
|
||||
|
||||
async def update_staff_password(staff_id: str, new_password: str, current_user_role: str) -> dict:
|
||||
db = get_db()
|
||||
doc_ref = db.collection("admin_users").document(staff_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Staff member not found")
|
||||
|
||||
existing = doc.to_dict()
|
||||
|
||||
# Admin cannot change sysadmin password
|
||||
if current_user_role == "admin" and existing.get("role") == "sysadmin":
|
||||
raise AuthorizationError("Admin cannot modify sysadmin accounts")
|
||||
|
||||
hashed = hash_password(new_password)
|
||||
doc_ref.update({"hashed_password": hashed})
|
||||
|
||||
return {"message": "Password updated successfully"}
|
||||
|
||||
|
||||
async def delete_staff(staff_id: str, current_user_role: str, current_user_id: str) -> dict:
|
||||
db = get_db()
|
||||
doc_ref = db.collection("admin_users").document(staff_id)
|
||||
doc = doc_ref.get()
|
||||
if not doc.exists:
|
||||
raise NotFoundError("Staff member not found")
|
||||
|
||||
existing = doc.to_dict()
|
||||
|
||||
# Cannot delete self
|
||||
if staff_id == current_user_id:
|
||||
raise AuthorizationError("Cannot delete your own account")
|
||||
|
||||
# Admin cannot delete sysadmin
|
||||
if current_user_role == "admin" and existing.get("role") == "sysadmin":
|
||||
raise AuthorizationError("Admin cannot delete sysadmin accounts")
|
||||
|
||||
doc_ref.delete()
|
||||
return {"message": "Staff member deleted"}
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import Optional, List
|
||||
from auth.models import TokenPayload
|
||||
from auth.dependencies import require_user_access, require_viewer
|
||||
from auth.dependencies import require_permission
|
||||
from users.models import (
|
||||
UserCreate, UserUpdate, UserInDB, UserListResponse,
|
||||
)
|
||||
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
async def list_users(
|
||||
search: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "view")),
|
||||
):
|
||||
users = service.list_users(search=search, status=status)
|
||||
return UserListResponse(users=users, total=len(users))
|
||||
@@ -23,7 +23,7 @@ async def list_users(
|
||||
@router.get("/{user_id}", response_model=UserInDB)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "view")),
|
||||
):
|
||||
return service.get_user(user_id)
|
||||
|
||||
@@ -31,7 +31,7 @@ async def get_user(
|
||||
@router.post("", response_model=UserInDB, status_code=201)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
_user: TokenPayload = Depends(require_user_access),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "add")),
|
||||
):
|
||||
return service.create_user(body)
|
||||
|
||||
@@ -40,7 +40,7 @@ async def create_user(
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
body: UserUpdate,
|
||||
_user: TokenPayload = Depends(require_user_access),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||
):
|
||||
return service.update_user(user_id, body)
|
||||
|
||||
@@ -48,7 +48,7 @@ async def update_user(
|
||||
@router.delete("/{user_id}", status_code=204)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
_user: TokenPayload = Depends(require_user_access),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "delete")),
|
||||
):
|
||||
service.delete_user(user_id)
|
||||
|
||||
@@ -56,7 +56,7 @@ async def delete_user(
|
||||
@router.post("/{user_id}/block", response_model=UserInDB)
|
||||
async def block_user(
|
||||
user_id: str,
|
||||
_user: TokenPayload = Depends(require_user_access),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||
):
|
||||
return service.block_user(user_id)
|
||||
|
||||
@@ -64,7 +64,7 @@ async def block_user(
|
||||
@router.post("/{user_id}/unblock", response_model=UserInDB)
|
||||
async def unblock_user(
|
||||
user_id: str,
|
||||
_user: TokenPayload = Depends(require_user_access),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||
):
|
||||
return service.unblock_user(user_id)
|
||||
|
||||
@@ -72,7 +72,7 @@ async def unblock_user(
|
||||
@router.get("/{user_id}/devices", response_model=List[dict])
|
||||
async def get_user_devices(
|
||||
user_id: str,
|
||||
_user: TokenPayload = Depends(require_viewer),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "view")),
|
||||
):
|
||||
return service.get_user_devices(user_id)
|
||||
|
||||
@@ -81,7 +81,7 @@ async def get_user_devices(
|
||||
async def assign_device(
|
||||
user_id: str,
|
||||
device_id: str,
|
||||
_user: TokenPayload = Depends(require_user_access),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||
):
|
||||
return service.assign_device(user_id, device_id)
|
||||
|
||||
@@ -90,6 +90,6 @@ async def assign_device(
|
||||
async def unassign_device(
|
||||
user_id: str,
|
||||
device_id: str,
|
||||
_user: TokenPayload = Depends(require_user_access),
|
||||
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||
):
|
||||
return service.unassign_device(user_id, device_id)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,9 +298,7 @@ export default function DeviceDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Left column */}
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
@@ -315,51 +313,23 @@ export default function DeviceDetail() {
|
||||
</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>
|
||||
|
||||
{/* Location */}
|
||||
<SectionCard title="Location">
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<Field label="Location">{device.device_location}</Field>
|
||||
<Field label="Coordinates">
|
||||
<div>
|
||||
{coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"}
|
||||
{coords && (
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${coords.lat},${coords.lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 px-2 py-0.5 text-xs rounded-md inline-block"
|
||||
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Open in Maps
|
||||
</a>
|
||||
)}
|
||||
{/* 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>
|
||||
</Field>
|
||||
{locationName && (
|
||||
<Field label="Nearest Place">{locationName}</Field>
|
||||
)}
|
||||
</dl>
|
||||
{coords && (
|
||||
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)", height: 200 }}>
|
||||
<MapContainer center={[coords.lat, coords.lng]} zoom={13} style={{ height: "100%", width: "100%" }} scrollWheelZoom={false}>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={[coords.lat, coords.lng]} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Subscription */}
|
||||
@@ -389,88 +359,47 @@ export default function DeviceDetail() {
|
||||
</dl>
|
||||
</SectionCard>
|
||||
|
||||
{/* Device Settings (combined) */}
|
||||
<SectionCard title="Device Settings">
|
||||
{/* Subsection 1: Basic Attributes */}
|
||||
<Subsection title="Basic Attributes" isFirst>
|
||||
<Field label="Bell Guard"><BoolBadge value={attr.bellGuardOn} /></Field>
|
||||
<Field label="Warnings On"><BoolBadge value={attr.warningsOn} /></Field>
|
||||
<Field label="Bell Guard Safety"><BoolBadge value={attr.bellGuardSafetyOn} /></Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 2: Bell Settings */}
|
||||
<Subsection title="Bell Settings">
|
||||
<Field label="Has Bells"><BoolBadge value={attr.hasBells} /></Field>
|
||||
<Field label="Total Bells">{attr.totalBells}</Field>
|
||||
<Field label="Bell Outputs">{attr.bellOutputs?.length > 0 ? attr.bellOutputs.join(", ") : "-"}</Field>
|
||||
<Field label="Hammer Timings">{attr.hammerTimings?.length > 0 ? attr.hammerTimings.join(", ") : "-"}</Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 3.1: Clock Settings */}
|
||||
<Subsection title="Clock Settings">
|
||||
<Field label="Has Clock"><BoolBadge value={attr.hasClock} /></Field>
|
||||
<Field label="Odd Output">{clock.clockOutputs?.[0] ?? "-"}</Field>
|
||||
<Field label="Even Output">{clock.clockOutputs?.[1] ?? "-"}</Field>
|
||||
<Field label="Run Pulse">{clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"}</Field>
|
||||
<Field label="Pause Pulse">{clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"}</Field>
|
||||
<Field label="Alerts Status"><BoolBadge value={clock.ringAlertsMasterOn} yesLabel="ON" noLabel="OFF" /></Field>
|
||||
<Field label="Alerts Type"><span className="capitalize">{clock.ringAlerts || "-"}</span></Field>
|
||||
<Field label="Ring Intervals">{clock.ringIntervals}</Field>
|
||||
<Field label="Hour Bell">{clock.hourAlertsBell}</Field>
|
||||
<Field label="Half-Hour Bell">{clock.halfhourAlertsBell}</Field>
|
||||
<Field label="Quarter Bell">{clock.quarterAlertsBell}</Field>
|
||||
<Field label="Daytime Silence"><BoolBadge value={clock.isDaySilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
|
||||
{clock.isDaySilenceOn && (
|
||||
<Field label="Daytime Period">
|
||||
{formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)}
|
||||
</Field>
|
||||
{/* 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>
|
||||
{coords ? formatCoordinates(coords) : device.device_location_coordinates || "-"}
|
||||
{coords && (
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${coords.lat},${coords.lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 px-2 py-0.5 text-xs rounded-md inline-block"
|
||||
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Open in Maps
|
||||
</a>
|
||||
)}
|
||||
<Field label="Nighttime Silence"><BoolBadge value={clock.isNightSilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
|
||||
{clock.isNightSilenceOn && (
|
||||
<Field label="Nighttime Period">
|
||||
{formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)}
|
||||
</div>
|
||||
</Field>
|
||||
{locationName && (
|
||||
<Field label="Nearest Place">{locationName}</Field>
|
||||
)}
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 3.2: Backlight Settings */}
|
||||
<Subsection title="Backlight Settings">
|
||||
<Field label="Auto Backlight"><BoolBadge value={clock.isBacklightAutomationOn} yesLabel="ON" noLabel="OFF" /></Field>
|
||||
<Field label="Backlight Output">{clock.backlightOutput}</Field>
|
||||
<Field label="On Time">{formatTimestamp(clock.backlightTurnOnTime)}</Field>
|
||||
<Field label="Off Time">{formatTimestamp(clock.backlightTurnOffTime)}</Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 4: Network */}
|
||||
<Subsection title="Network">
|
||||
<Field label="Hostname">{net.hostname}</Field>
|
||||
<Field label="Has Static IP"><BoolBadge value={net.useStaticIP} /></Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 5: Logging */}
|
||||
<Subsection title="Logging">
|
||||
<Field label="Serial Log Level">{attr.serialLogLevel}</Field>
|
||||
<Field label="SD Log Level">{attr.sdLogLevel}</Field>
|
||||
<Field label="MQTT Log Level">{attr.mqttLogLevel ?? 0}</Field>
|
||||
</Subsection>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="space-y-6">
|
||||
{/* Misc */}
|
||||
<SectionCard title="Misc">
|
||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<Field label="Device Locale"><span className="capitalize">{attr.deviceLocale || "-"}</span></Field>
|
||||
<Field label="WebSocket URL">{device.websocket_url}</Field>
|
||||
<Field label="Has Assistant"><BoolBadge value={attr.hasAssistant} /></Field>
|
||||
<div className="col-span-2 md:col-span-3">
|
||||
<Field label="Church Assistant URL">{device.churchAssistantURL}</Field>
|
||||
</div>
|
||||
</dl>
|
||||
{coords && (
|
||||
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)", minHeight: 300 }}>
|
||||
<MapContainer center={[coords.lat, coords.lng]} zoom={13} style={{ height: "100%", width: "100%", minHeight: 300 }} scrollWheelZoom={false}>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={[coords.lat, coords.lng]} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Warranty & Maintenance & Statistics */}
|
||||
{/* Warranty, Maintenance & Statistics */}
|
||||
<SectionCard title="Warranty, Maintenance & Statistics">
|
||||
{/* Subsection 1: Warranty */}
|
||||
<Subsection title="Warranty Information" isFirst>
|
||||
@@ -539,7 +468,7 @@ export default function DeviceDetail() {
|
||||
</SectionCard>
|
||||
|
||||
{/* Users */}
|
||||
<SectionCard title={`Users (${deviceUsers.length})`}>
|
||||
<SectionCard title={`App Users (${deviceUsers.length})`}>
|
||||
{usersLoading ? (
|
||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users...</p>
|
||||
) : deviceUsers.length === 0 ? (
|
||||
@@ -577,10 +506,77 @@ export default function DeviceDetail() {
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Device Settings — spans 2 columns on ultrawide */}
|
||||
<div className="2xl:col-span-2">
|
||||
<SectionCard title="Device Settings">
|
||||
{/* Subsection 1: Basic Attributes */}
|
||||
<Subsection title="Basic Attributes" isFirst>
|
||||
<Field label="Bell Guard"><BoolBadge value={attr.bellGuardOn} /></Field>
|
||||
<Field label="Warnings On"><BoolBadge value={attr.warningsOn} /></Field>
|
||||
<Field label="Bell Guard Safety"><BoolBadge value={attr.bellGuardSafetyOn} /></Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 2: Bell Settings */}
|
||||
<Subsection title="Bell Settings">
|
||||
<Field label="Has Bells"><BoolBadge value={attr.hasBells} /></Field>
|
||||
<Field label="Total Bells">{attr.totalBells}</Field>
|
||||
<Field label="Bell Outputs">{attr.bellOutputs?.length > 0 ? attr.bellOutputs.join(", ") : "-"}</Field>
|
||||
<Field label="Hammer Timings">{attr.hammerTimings?.length > 0 ? attr.hammerTimings.join(", ") : "-"}</Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 3.1: Clock Settings */}
|
||||
<Subsection title="Clock Settings">
|
||||
<Field label="Has Clock"><BoolBadge value={attr.hasClock} /></Field>
|
||||
<Field label="Odd Output">{clock.clockOutputs?.[0] ?? "-"}</Field>
|
||||
<Field label="Even Output">{clock.clockOutputs?.[1] ?? "-"}</Field>
|
||||
<Field label="Run Pulse">{clock.clockTimings?.[0] != null ? msToSeconds(clock.clockTimings[0]) : "-"}</Field>
|
||||
<Field label="Pause Pulse">{clock.clockTimings?.[1] != null ? msToSeconds(clock.clockTimings[1]) : "-"}</Field>
|
||||
<Field label="Alerts Status"><BoolBadge value={clock.ringAlertsMasterOn} yesLabel="ON" noLabel="OFF" /></Field>
|
||||
<Field label="Alerts Type"><span className="capitalize">{clock.ringAlerts || "-"}</span></Field>
|
||||
<Field label="Ring Intervals">{clock.ringIntervals}</Field>
|
||||
<Field label="Hour Bell">{clock.hourAlertsBell}</Field>
|
||||
<Field label="Half-Hour Bell">{clock.halfhourAlertsBell}</Field>
|
||||
<Field label="Quarter Bell">{clock.quarterAlertsBell}</Field>
|
||||
<Field label="Daytime Silence"><BoolBadge value={clock.isDaySilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
|
||||
{clock.isDaySilenceOn && (
|
||||
<Field label="Daytime Period">
|
||||
{formatTimestamp(clock.daySilenceFrom)} - {formatTimestamp(clock.daySilenceTo)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Nighttime Silence"><BoolBadge value={clock.isNightSilenceOn} yesLabel="ON" noLabel="OFF" /></Field>
|
||||
{clock.isNightSilenceOn && (
|
||||
<Field label="Nighttime Period">
|
||||
{formatTimestamp(clock.nightSilenceFrom)} - {formatTimestamp(clock.nightSilenceTo)}
|
||||
</Field>
|
||||
)}
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 3.2: Backlight Settings */}
|
||||
<Subsection title="Backlight Settings">
|
||||
<Field label="Auto Backlight"><BoolBadge value={clock.isBacklightAutomationOn} yesLabel="ON" noLabel="OFF" /></Field>
|
||||
<Field label="Backlight Output">{clock.backlightOutput}</Field>
|
||||
<Field label="On Time">{formatTimestamp(clock.backlightTurnOnTime)}</Field>
|
||||
<Field label="Off Time">{formatTimestamp(clock.backlightTurnOffTime)}</Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 4: Network */}
|
||||
<Subsection title="Network">
|
||||
<Field label="Hostname">{net.hostname}</Field>
|
||||
<Field label="Has Static IP"><BoolBadge value={net.useStaticIP} /></Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 5: Logging */}
|
||||
<Subsection title="Logging">
|
||||
<Field label="Serial Log Level">{attr.serialLogLevel}</Field>
|
||||
<Field label="SD Log Level">{attr.sdLogLevel}</Field>
|
||||
<Field label="MQTT Log Level">{attr.mqttLogLevel ?? 0}</Field>
|
||||
</Subsection>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
{/* Equipment Notes */}
|
||||
<NotesPanel deviceId={id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showDelete}
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") || "";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
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()}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{item.label}
|
||||
</NavLink>
|
||||
)
|
||||
{!hasAccess && (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 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,14 +120,24 @@ 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
|
||||
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>
|
||||
<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"
|
||||
@@ -105,8 +146,9 @@ function CollapsibleGroup({ label, children, currentPath }) {
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -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({});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
283
frontend/src/settings/StaffDetail.jsx
Normal file
283
frontend/src/settings/StaffDetail.jsx
Normal 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)" }}>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
320
frontend/src/settings/StaffForm.jsx
Normal file
320
frontend/src/settings/StaffForm.jsx
Normal 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)" }}>
|
||||
← {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>
|
||||
);
|
||||
}
|
||||
209
frontend/src/settings/StaffList.jsx
Normal file
209
frontend/src/settings/StaffList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)" }}
|
||||
>
|
||||
← Back to Users
|
||||
← Back to App Users
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user