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.utils import decode_access_token
|
||||||
from auth.models import TokenPayload, Role
|
from auth.models import TokenPayload, Role
|
||||||
from shared.exceptions import AuthenticationError, AuthorizationError
|
from shared.exceptions import AuthenticationError, AuthorizationError
|
||||||
|
from shared.firebase import get_db
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ def require_roles(*allowed_roles: Role):
|
|||||||
async def role_checker(
|
async def role_checker(
|
||||||
current_user: TokenPayload = Depends(get_current_user),
|
current_user: TokenPayload = Depends(get_current_user),
|
||||||
) -> TokenPayload:
|
) -> TokenPayload:
|
||||||
if current_user.role == Role.superadmin:
|
if current_user.role == Role.sysadmin:
|
||||||
return current_user
|
return current_user
|
||||||
if current_user.role not in [r.value for r in allowed_roles]:
|
if current_user.role not in [r.value for r in allowed_roles]:
|
||||||
raise AuthorizationError()
|
raise AuthorizationError()
|
||||||
@@ -36,12 +37,63 @@ def require_roles(*allowed_roles: Role):
|
|||||||
return role_checker
|
return role_checker
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_user_permissions(user: TokenPayload) -> dict:
|
||||||
|
"""Fetch permissions from Firestore for the given user."""
|
||||||
|
if user.role in (Role.sysadmin, Role.admin):
|
||||||
|
return None # Full access
|
||||||
|
db = get_db()
|
||||||
|
if not db:
|
||||||
|
raise AuthorizationError()
|
||||||
|
doc = db.collection("admin_users").document(user.sub).get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise AuthorizationError()
|
||||||
|
data = doc.to_dict()
|
||||||
|
return data.get("permissions")
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(section: str, action: str):
|
||||||
|
"""Check granular permission for a section and action.
|
||||||
|
section: 'melodies', 'devices', 'app_users', 'equipment', 'mqtt'
|
||||||
|
action: 'view', 'add', 'edit', 'delete' (or ignored for mqtt)
|
||||||
|
"""
|
||||||
|
async def permission_checker(
|
||||||
|
current_user: TokenPayload = Depends(get_current_user),
|
||||||
|
) -> TokenPayload:
|
||||||
|
# sysadmin and admin have full access
|
||||||
|
if current_user.role in (Role.sysadmin, Role.admin):
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
permissions = await _get_user_permissions(current_user)
|
||||||
|
if not permissions:
|
||||||
|
raise AuthorizationError()
|
||||||
|
|
||||||
|
if section == "mqtt":
|
||||||
|
if not permissions.get("mqtt", False):
|
||||||
|
raise AuthorizationError()
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
section_perms = permissions.get(section)
|
||||||
|
if not section_perms:
|
||||||
|
raise AuthorizationError()
|
||||||
|
|
||||||
|
if isinstance(section_perms, dict):
|
||||||
|
if not section_perms.get(action, False):
|
||||||
|
raise AuthorizationError()
|
||||||
|
else:
|
||||||
|
raise AuthorizationError()
|
||||||
|
|
||||||
|
return current_user
|
||||||
|
return permission_checker
|
||||||
|
|
||||||
|
|
||||||
# Pre-built convenience dependencies
|
# Pre-built convenience dependencies
|
||||||
require_superadmin = require_roles(Role.superadmin)
|
require_sysadmin = require_roles(Role.sysadmin)
|
||||||
require_melody_access = require_roles(Role.superadmin, Role.melody_editor)
|
require_admin_or_above = require_roles(Role.sysadmin, Role.admin)
|
||||||
require_device_access = require_roles(Role.superadmin, Role.device_manager)
|
|
||||||
require_user_access = require_roles(Role.superadmin, Role.user_manager)
|
# Staff management: only sysadmin and admin
|
||||||
require_viewer = require_roles(
|
require_staff_management = require_roles(Role.sysadmin, Role.admin)
|
||||||
Role.superadmin, Role.melody_editor, Role.device_manager,
|
|
||||||
Role.user_manager, Role.viewer,
|
# Viewer-level: any authenticated user (actual permission check per-action)
|
||||||
|
require_any_authenticated = require_roles(
|
||||||
|
Role.sysadmin, Role.admin, Role.editor, Role.user,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,11 +4,49 @@ from enum import Enum
|
|||||||
|
|
||||||
|
|
||||||
class Role(str, Enum):
|
class Role(str, Enum):
|
||||||
superadmin = "superadmin"
|
sysadmin = "sysadmin"
|
||||||
melody_editor = "melody_editor"
|
admin = "admin"
|
||||||
device_manager = "device_manager"
|
editor = "editor"
|
||||||
user_manager = "user_manager"
|
user = "user"
|
||||||
viewer = "viewer"
|
|
||||||
|
|
||||||
|
class SectionPermissions(BaseModel):
|
||||||
|
view: bool = False
|
||||||
|
add: bool = False
|
||||||
|
edit: bool = False
|
||||||
|
delete: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class StaffPermissions(BaseModel):
|
||||||
|
melodies: SectionPermissions = SectionPermissions()
|
||||||
|
devices: SectionPermissions = SectionPermissions()
|
||||||
|
app_users: SectionPermissions = SectionPermissions()
|
||||||
|
equipment: SectionPermissions = SectionPermissions()
|
||||||
|
mqtt: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# Default permissions per role
|
||||||
|
def default_permissions_for_role(role: str) -> Optional[dict]:
|
||||||
|
if role in ("sysadmin", "admin"):
|
||||||
|
return None # Full access, permissions field not used
|
||||||
|
full = {"view": True, "add": True, "edit": True, "delete": True}
|
||||||
|
view_only = {"view": True, "add": False, "edit": False, "delete": False}
|
||||||
|
if role == "editor":
|
||||||
|
return {
|
||||||
|
"melodies": full,
|
||||||
|
"devices": full,
|
||||||
|
"app_users": full,
|
||||||
|
"equipment": full,
|
||||||
|
"mqtt": True,
|
||||||
|
}
|
||||||
|
# user role - view only
|
||||||
|
return {
|
||||||
|
"melodies": view_only,
|
||||||
|
"devices": view_only,
|
||||||
|
"app_users": view_only,
|
||||||
|
"equipment": view_only,
|
||||||
|
"mqtt": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AdminUserInDB(BaseModel):
|
class AdminUserInDB(BaseModel):
|
||||||
@@ -18,6 +56,7 @@ class AdminUserInDB(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
role: Role
|
role: Role
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
permissions: Optional[StaffPermissions] = None
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
@@ -30,6 +69,7 @@ class TokenResponse(BaseModel):
|
|||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
role: str
|
role: str
|
||||||
name: str
|
name: str
|
||||||
|
permissions: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
class TokenPayload(BaseModel):
|
class TokenPayload(BaseModel):
|
||||||
|
|||||||
@@ -28,15 +28,32 @@ async def login(body: LoginRequest):
|
|||||||
if not verify_password(body.password, user_data["hashed_password"]):
|
if not verify_password(body.password, user_data["hashed_password"]):
|
||||||
raise AuthenticationError("Invalid email or password")
|
raise AuthenticationError("Invalid email or password")
|
||||||
|
|
||||||
|
role = user_data["role"]
|
||||||
|
# Map legacy roles to new roles
|
||||||
|
role_mapping = {
|
||||||
|
"superadmin": "sysadmin",
|
||||||
|
"melody_editor": "editor",
|
||||||
|
"device_manager": "editor",
|
||||||
|
"user_manager": "editor",
|
||||||
|
"viewer": "user",
|
||||||
|
}
|
||||||
|
role = role_mapping.get(role, role)
|
||||||
|
|
||||||
token = create_access_token({
|
token = create_access_token({
|
||||||
"sub": doc.id,
|
"sub": doc.id,
|
||||||
"email": user_data["email"],
|
"email": user_data["email"],
|
||||||
"role": user_data["role"],
|
"role": role,
|
||||||
"name": user_data["name"],
|
"name": user_data["name"],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Get permissions for editor/user roles
|
||||||
|
permissions = None
|
||||||
|
if role in ("editor", "user"):
|
||||||
|
permissions = user_data.get("permissions")
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=token,
|
access_token=token,
|
||||||
role=user_data["role"],
|
role=role,
|
||||||
name=user_data["name"],
|
name=user_data["name"],
|
||||||
|
permissions=permissions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_device_access, require_viewer
|
from auth.dependencies import require_permission
|
||||||
from devices.models import (
|
from devices.models import (
|
||||||
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
|
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
|
||||||
DeviceUsersResponse, DeviceUserInfo,
|
DeviceUsersResponse, DeviceUserInfo,
|
||||||
@@ -16,7 +16,7 @@ async def list_devices(
|
|||||||
search: Optional[str] = Query(None),
|
search: Optional[str] = Query(None),
|
||||||
online: Optional[bool] = Query(None),
|
online: Optional[bool] = Query(None),
|
||||||
tier: Optional[str] = Query(None),
|
tier: Optional[str] = Query(None),
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||||
):
|
):
|
||||||
devices = service.list_devices(
|
devices = service.list_devices(
|
||||||
search=search,
|
search=search,
|
||||||
@@ -29,7 +29,7 @@ async def list_devices(
|
|||||||
@router.get("/{device_id}", response_model=DeviceInDB)
|
@router.get("/{device_id}", response_model=DeviceInDB)
|
||||||
async def get_device(
|
async def get_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||||
):
|
):
|
||||||
return service.get_device(device_id)
|
return service.get_device(device_id)
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ async def get_device(
|
|||||||
@router.get("/{device_id}/users", response_model=DeviceUsersResponse)
|
@router.get("/{device_id}/users", response_model=DeviceUsersResponse)
|
||||||
async def get_device_users(
|
async def get_device_users(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||||
):
|
):
|
||||||
users_data = service.get_device_users(device_id)
|
users_data = service.get_device_users(device_id)
|
||||||
users = [DeviceUserInfo(**u) for u in users_data]
|
users = [DeviceUserInfo(**u) for u in users_data]
|
||||||
@@ -47,7 +47,7 @@ async def get_device_users(
|
|||||||
@router.post("", response_model=DeviceInDB, status_code=201)
|
@router.post("", response_model=DeviceInDB, status_code=201)
|
||||||
async def create_device(
|
async def create_device(
|
||||||
body: DeviceCreate,
|
body: DeviceCreate,
|
||||||
_user: TokenPayload = Depends(require_device_access),
|
_user: TokenPayload = Depends(require_permission("devices", "add")),
|
||||||
):
|
):
|
||||||
return service.create_device(body)
|
return service.create_device(body)
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ async def create_device(
|
|||||||
async def update_device(
|
async def update_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
body: DeviceUpdate,
|
body: DeviceUpdate,
|
||||||
_user: TokenPayload = Depends(require_device_access),
|
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||||
):
|
):
|
||||||
return service.update_device(device_id, body)
|
return service.update_device(device_id, body)
|
||||||
|
|
||||||
@@ -64,6 +64,6 @@ async def update_device(
|
|||||||
@router.delete("/{device_id}", status_code=204)
|
@router.delete("/{device_id}", status_code=204)
|
||||||
async def delete_device(
|
async def delete_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
_user: TokenPayload = Depends(require_device_access),
|
_user: TokenPayload = Depends(require_permission("devices", "delete")),
|
||||||
):
|
):
|
||||||
service.delete_device(device_id)
|
service.delete_device(device_id)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_device_access, require_viewer
|
from auth.dependencies import require_permission
|
||||||
from equipment.models import (
|
from equipment.models import (
|
||||||
NoteCreate, NoteUpdate, NoteInDB, NoteListResponse,
|
NoteCreate, NoteUpdate, NoteInDB, NoteListResponse,
|
||||||
)
|
)
|
||||||
@@ -16,7 +16,7 @@ async def list_notes(
|
|||||||
category: Optional[str] = Query(None),
|
category: Optional[str] = Query(None),
|
||||||
device_id: Optional[str] = Query(None),
|
device_id: Optional[str] = Query(None),
|
||||||
user_id: Optional[str] = Query(None),
|
user_id: Optional[str] = Query(None),
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("equipment", "view")),
|
||||||
):
|
):
|
||||||
notes = service.list_notes(
|
notes = service.list_notes(
|
||||||
search=search, category=category,
|
search=search, category=category,
|
||||||
@@ -28,7 +28,7 @@ async def list_notes(
|
|||||||
@router.get("/{note_id}", response_model=NoteInDB)
|
@router.get("/{note_id}", response_model=NoteInDB)
|
||||||
async def get_note(
|
async def get_note(
|
||||||
note_id: str,
|
note_id: str,
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("equipment", "view")),
|
||||||
):
|
):
|
||||||
return service.get_note(note_id)
|
return service.get_note(note_id)
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ async def get_note(
|
|||||||
@router.post("", response_model=NoteInDB, status_code=201)
|
@router.post("", response_model=NoteInDB, status_code=201)
|
||||||
async def create_note(
|
async def create_note(
|
||||||
body: NoteCreate,
|
body: NoteCreate,
|
||||||
_user: TokenPayload = Depends(require_device_access),
|
_user: TokenPayload = Depends(require_permission("equipment", "add")),
|
||||||
):
|
):
|
||||||
return service.create_note(body, created_by=_user.name or _user.email)
|
return service.create_note(body, created_by=_user.name or _user.email)
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ async def create_note(
|
|||||||
async def update_note(
|
async def update_note(
|
||||||
note_id: str,
|
note_id: str,
|
||||||
body: NoteUpdate,
|
body: NoteUpdate,
|
||||||
_user: TokenPayload = Depends(require_device_access),
|
_user: TokenPayload = Depends(require_permission("equipment", "edit")),
|
||||||
):
|
):
|
||||||
return service.update_note(note_id, body)
|
return service.update_note(note_id, body)
|
||||||
|
|
||||||
@@ -53,6 +53,6 @@ async def update_note(
|
|||||||
@router.delete("/{note_id}", status_code=204)
|
@router.delete("/{note_id}", status_code=204)
|
||||||
async def delete_note(
|
async def delete_note(
|
||||||
note_id: str,
|
note_id: str,
|
||||||
_user: TokenPayload = Depends(require_device_access),
|
_user: TokenPayload = Depends(require_permission("equipment", "delete")),
|
||||||
):
|
):
|
||||||
service.delete_note(note_id)
|
service.delete_note(note_id)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from settings.router import router as settings_router
|
|||||||
from users.router import router as users_router
|
from users.router import router as users_router
|
||||||
from mqtt.router import router as mqtt_router
|
from mqtt.router import router as mqtt_router
|
||||||
from equipment.router import router as equipment_router
|
from equipment.router import router as equipment_router
|
||||||
|
from staff.router import router as staff_router
|
||||||
from mqtt.client import mqtt_manager
|
from mqtt.client import mqtt_manager
|
||||||
from mqtt import database as mqtt_db
|
from mqtt import database as mqtt_db
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ app.include_router(settings_router)
|
|||||||
app.include_router(users_router)
|
app.include_router(users_router)
|
||||||
app.include_router(mqtt_router)
|
app.include_router(mqtt_router)
|
||||||
app.include_router(equipment_router)
|
app.include_router(equipment_router)
|
||||||
|
app.include_router(staff_router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException
|
from fastapi import APIRouter, Depends, UploadFile, File, Query, HTTPException
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_melody_access, require_viewer
|
from auth.dependencies import require_permission
|
||||||
from melodies.models import (
|
from melodies.models import (
|
||||||
MelodyCreate, MelodyUpdate, MelodyInDB, MelodyListResponse, MelodyInfo,
|
MelodyCreate, MelodyUpdate, MelodyInDB, MelodyListResponse, MelodyInfo,
|
||||||
)
|
)
|
||||||
@@ -16,7 +16,7 @@ async def list_melodies(
|
|||||||
type: Optional[str] = Query(None),
|
type: Optional[str] = Query(None),
|
||||||
tone: Optional[str] = Query(None),
|
tone: Optional[str] = Query(None),
|
||||||
total_notes: Optional[int] = Query(None),
|
total_notes: Optional[int] = Query(None),
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
||||||
):
|
):
|
||||||
melodies = service.list_melodies(
|
melodies = service.list_melodies(
|
||||||
search=search,
|
search=search,
|
||||||
@@ -30,7 +30,7 @@ async def list_melodies(
|
|||||||
@router.get("/{melody_id}", response_model=MelodyInDB)
|
@router.get("/{melody_id}", response_model=MelodyInDB)
|
||||||
async def get_melody(
|
async def get_melody(
|
||||||
melody_id: str,
|
melody_id: str,
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
||||||
):
|
):
|
||||||
return service.get_melody(melody_id)
|
return service.get_melody(melody_id)
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ async def get_melody(
|
|||||||
@router.post("", response_model=MelodyInDB, status_code=201)
|
@router.post("", response_model=MelodyInDB, status_code=201)
|
||||||
async def create_melody(
|
async def create_melody(
|
||||||
body: MelodyCreate,
|
body: MelodyCreate,
|
||||||
_user: TokenPayload = Depends(require_melody_access),
|
_user: TokenPayload = Depends(require_permission("melodies", "add")),
|
||||||
):
|
):
|
||||||
return service.create_melody(body)
|
return service.create_melody(body)
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ async def create_melody(
|
|||||||
async def update_melody(
|
async def update_melody(
|
||||||
melody_id: str,
|
melody_id: str,
|
||||||
body: MelodyUpdate,
|
body: MelodyUpdate,
|
||||||
_user: TokenPayload = Depends(require_melody_access),
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||||
):
|
):
|
||||||
return service.update_melody(melody_id, body)
|
return service.update_melody(melody_id, body)
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ async def update_melody(
|
|||||||
@router.delete("/{melody_id}", status_code=204)
|
@router.delete("/{melody_id}", status_code=204)
|
||||||
async def delete_melody(
|
async def delete_melody(
|
||||||
melody_id: str,
|
melody_id: str,
|
||||||
_user: TokenPayload = Depends(require_melody_access),
|
_user: TokenPayload = Depends(require_permission("melodies", "delete")),
|
||||||
):
|
):
|
||||||
service.delete_melody(melody_id)
|
service.delete_melody(melody_id)
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ async def upload_file(
|
|||||||
melody_id: str,
|
melody_id: str,
|
||||||
file_type: str,
|
file_type: str,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
_user: TokenPayload = Depends(require_melody_access),
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||||
):
|
):
|
||||||
"""Upload a binary or preview file. file_type must be 'binary' or 'preview'."""
|
"""Upload a binary or preview file. file_type must be 'binary' or 'preview'."""
|
||||||
if file_type not in ("binary", "preview"):
|
if file_type not in ("binary", "preview"):
|
||||||
@@ -100,7 +100,7 @@ async def upload_file(
|
|||||||
async def delete_file(
|
async def delete_file(
|
||||||
melody_id: str,
|
melody_id: str,
|
||||||
file_type: str,
|
file_type: str,
|
||||||
_user: TokenPayload = Depends(require_melody_access),
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||||
):
|
):
|
||||||
"""Delete a binary or preview file. file_type must be 'binary' or 'preview'."""
|
"""Delete a binary or preview file. file_type must be 'binary' or 'preview'."""
|
||||||
if file_type not in ("binary", "preview"):
|
if file_type not in ("binary", "preview"):
|
||||||
@@ -113,7 +113,7 @@ async def delete_file(
|
|||||||
@router.get("/{melody_id}/files")
|
@router.get("/{melody_id}/files")
|
||||||
async def get_files(
|
async def get_files(
|
||||||
melody_id: str,
|
melody_id: str,
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
||||||
):
|
):
|
||||||
"""Get storage file URLs for a melody."""
|
"""Get storage file URLs for a melody."""
|
||||||
service.get_melody(melody_id)
|
service.get_melody(melody_id)
|
||||||
|
|||||||
103
backend/migrate_roles.py
Normal file
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 fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_device_access, require_viewer
|
from auth.dependencies import require_permission
|
||||||
from mqtt.models import (
|
from mqtt.models import (
|
||||||
MqttCommandRequest, CommandSendResponse, MqttStatusResponse,
|
MqttCommandRequest, CommandSendResponse, MqttStatusResponse,
|
||||||
DeviceMqttStatus, LogListResponse, HeartbeatListResponse,
|
DeviceMqttStatus, LogListResponse, HeartbeatListResponse,
|
||||||
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/api/mqtt", tags=["mqtt"])
|
|||||||
|
|
||||||
@router.get("/status", response_model=MqttStatusResponse)
|
@router.get("/status", response_model=MqttStatusResponse)
|
||||||
async def get_all_device_status(
|
async def get_all_device_status(
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||||
):
|
):
|
||||||
heartbeats = await db.get_latest_heartbeats()
|
heartbeats = await db.get_latest_heartbeats()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -47,7 +47,7 @@ async def get_all_device_status(
|
|||||||
async def send_command(
|
async def send_command(
|
||||||
device_serial: str,
|
device_serial: str,
|
||||||
body: MqttCommandRequest,
|
body: MqttCommandRequest,
|
||||||
_user: TokenPayload = Depends(require_device_access),
|
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||||
):
|
):
|
||||||
command_id = await db.insert_command(
|
command_id = await db.insert_command(
|
||||||
device_serial=device_serial,
|
device_serial=device_serial,
|
||||||
@@ -84,7 +84,7 @@ async def get_device_logs(
|
|||||||
search: Optional[str] = Query(None),
|
search: Optional[str] = Query(None),
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||||
):
|
):
|
||||||
logs, total = await db.get_logs(
|
logs, total = await db.get_logs(
|
||||||
device_serial, level=level, search=search,
|
device_serial, level=level, search=search,
|
||||||
@@ -98,7 +98,7 @@ async def get_device_heartbeats(
|
|||||||
device_serial: str,
|
device_serial: str,
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||||
):
|
):
|
||||||
heartbeats, total = await db.get_heartbeats(
|
heartbeats, total = await db.get_heartbeats(
|
||||||
device_serial, limit=limit, offset=offset,
|
device_serial, limit=limit, offset=offset,
|
||||||
@@ -111,7 +111,7 @@ async def get_device_commands(
|
|||||||
device_serial: str,
|
device_serial: str,
|
||||||
limit: int = Query(100, ge=1, le=1000),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||||
):
|
):
|
||||||
commands, total = await db.get_commands(
|
commands, total = await db.get_commands(
|
||||||
device_serial, limit=limit, offset=offset,
|
device_serial, limit=limit, offset=offset,
|
||||||
@@ -129,12 +129,28 @@ async def mqtt_websocket(websocket: WebSocket):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from auth.utils import decode_access_token
|
from auth.utils import decode_access_token
|
||||||
|
from shared.firebase import get_db
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
role = payload.get("role", "")
|
role = payload.get("role", "")
|
||||||
allowed = {"superadmin", "device_manager", "viewer"}
|
|
||||||
if role not in allowed:
|
# sysadmin and admin always have MQTT access
|
||||||
await websocket.close(code=4003, reason="Insufficient permissions")
|
if role not in ("sysadmin", "admin"):
|
||||||
return
|
# 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:
|
except Exception:
|
||||||
await websocket.close(code=4001, reason="Invalid token")
|
await websocket.close(code=4001, reason="Invalid token")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -34,15 +34,15 @@ def seed_superadmin(email: str, password: str, name: str):
|
|||||||
"email": email,
|
"email": email,
|
||||||
"hashed_password": hash_password(password),
|
"hashed_password": hash_password(password),
|
||||||
"name": name,
|
"name": name,
|
||||||
"role": "superadmin",
|
"role": "sysadmin",
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
doc_ref = db.collection("admin_users").add(user_data)
|
doc_ref = db.collection("admin_users").add(user_data)
|
||||||
print(f"Superadmin created successfully!")
|
print(f"SysAdmin created successfully!")
|
||||||
print(f" Email: {email}")
|
print(f" Email: {email}")
|
||||||
print(f" Name: {name}")
|
print(f" Name: {name}")
|
||||||
print(f" Role: superadmin")
|
print(f" Role: sysadmin")
|
||||||
print(f" Doc ID: {doc_ref[1].id}")
|
print(f" Doc ID: {doc_ref[1].id}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_melody_access, require_viewer
|
from auth.dependencies import require_permission
|
||||||
from settings.models import MelodySettings, MelodySettingsUpdate
|
from settings.models import MelodySettings, MelodySettingsUpdate
|
||||||
from settings import service
|
from settings import service
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|||||||
|
|
||||||
@router.get("/melody", response_model=MelodySettings)
|
@router.get("/melody", response_model=MelodySettings)
|
||||||
async def get_melody_settings(
|
async def get_melody_settings(
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
||||||
):
|
):
|
||||||
return service.get_melody_settings()
|
return service.get_melody_settings()
|
||||||
|
|
||||||
@@ -17,6 +17,6 @@ async def get_melody_settings(
|
|||||||
@router.put("/melody", response_model=MelodySettings)
|
@router.put("/melody", response_model=MelodySettings)
|
||||||
async def update_melody_settings(
|
async def update_melody_settings(
|
||||||
body: MelodySettingsUpdate,
|
body: MelodySettingsUpdate,
|
||||||
_user: TokenPayload = Depends(require_melody_access),
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||||
):
|
):
|
||||||
return service.update_melody_settings(body)
|
return service.update_melody_settings(body)
|
||||||
|
|||||||
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 fastapi import APIRouter, Depends, Query
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_user_access, require_viewer
|
from auth.dependencies import require_permission
|
||||||
from users.models import (
|
from users.models import (
|
||||||
UserCreate, UserUpdate, UserInDB, UserListResponse,
|
UserCreate, UserUpdate, UserInDB, UserListResponse,
|
||||||
)
|
)
|
||||||
@@ -14,7 +14,7 @@ router = APIRouter(prefix="/api/users", tags=["users"])
|
|||||||
async def list_users(
|
async def list_users(
|
||||||
search: Optional[str] = Query(None),
|
search: Optional[str] = Query(None),
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("app_users", "view")),
|
||||||
):
|
):
|
||||||
users = service.list_users(search=search, status=status)
|
users = service.list_users(search=search, status=status)
|
||||||
return UserListResponse(users=users, total=len(users))
|
return UserListResponse(users=users, total=len(users))
|
||||||
@@ -23,7 +23,7 @@ async def list_users(
|
|||||||
@router.get("/{user_id}", response_model=UserInDB)
|
@router.get("/{user_id}", response_model=UserInDB)
|
||||||
async def get_user(
|
async def get_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("app_users", "view")),
|
||||||
):
|
):
|
||||||
return service.get_user(user_id)
|
return service.get_user(user_id)
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ async def get_user(
|
|||||||
@router.post("", response_model=UserInDB, status_code=201)
|
@router.post("", response_model=UserInDB, status_code=201)
|
||||||
async def create_user(
|
async def create_user(
|
||||||
body: UserCreate,
|
body: UserCreate,
|
||||||
_user: TokenPayload = Depends(require_user_access),
|
_user: TokenPayload = Depends(require_permission("app_users", "add")),
|
||||||
):
|
):
|
||||||
return service.create_user(body)
|
return service.create_user(body)
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ async def create_user(
|
|||||||
async def update_user(
|
async def update_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
body: UserUpdate,
|
body: UserUpdate,
|
||||||
_user: TokenPayload = Depends(require_user_access),
|
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||||
):
|
):
|
||||||
return service.update_user(user_id, body)
|
return service.update_user(user_id, body)
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ async def update_user(
|
|||||||
@router.delete("/{user_id}", status_code=204)
|
@router.delete("/{user_id}", status_code=204)
|
||||||
async def delete_user(
|
async def delete_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
_user: TokenPayload = Depends(require_user_access),
|
_user: TokenPayload = Depends(require_permission("app_users", "delete")),
|
||||||
):
|
):
|
||||||
service.delete_user(user_id)
|
service.delete_user(user_id)
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ async def delete_user(
|
|||||||
@router.post("/{user_id}/block", response_model=UserInDB)
|
@router.post("/{user_id}/block", response_model=UserInDB)
|
||||||
async def block_user(
|
async def block_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
_user: TokenPayload = Depends(require_user_access),
|
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||||
):
|
):
|
||||||
return service.block_user(user_id)
|
return service.block_user(user_id)
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ async def block_user(
|
|||||||
@router.post("/{user_id}/unblock", response_model=UserInDB)
|
@router.post("/{user_id}/unblock", response_model=UserInDB)
|
||||||
async def unblock_user(
|
async def unblock_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
_user: TokenPayload = Depends(require_user_access),
|
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||||
):
|
):
|
||||||
return service.unblock_user(user_id)
|
return service.unblock_user(user_id)
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ async def unblock_user(
|
|||||||
@router.get("/{user_id}/devices", response_model=List[dict])
|
@router.get("/{user_id}/devices", response_model=List[dict])
|
||||||
async def get_user_devices(
|
async def get_user_devices(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
_user: TokenPayload = Depends(require_viewer),
|
_user: TokenPayload = Depends(require_permission("app_users", "view")),
|
||||||
):
|
):
|
||||||
return service.get_user_devices(user_id)
|
return service.get_user_devices(user_id)
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ async def get_user_devices(
|
|||||||
async def assign_device(
|
async def assign_device(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
_user: TokenPayload = Depends(require_user_access),
|
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||||
):
|
):
|
||||||
return service.assign_device(user_id, device_id)
|
return service.assign_device(user_id, device_id)
|
||||||
|
|
||||||
@@ -90,6 +90,6 @@ async def assign_device(
|
|||||||
async def unassign_device(
|
async def unassign_device(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
_user: TokenPayload = Depends(require_user_access),
|
_user: TokenPayload = Depends(require_permission("app_users", "edit")),
|
||||||
):
|
):
|
||||||
return service.unassign_device(user_id, device_id)
|
return service.unassign_device(user_id, device_id)
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import LogViewer from "./mqtt/LogViewer";
|
|||||||
import NoteList from "./equipment/NoteList";
|
import NoteList from "./equipment/NoteList";
|
||||||
import NoteDetail from "./equipment/NoteDetail";
|
import NoteDetail from "./equipment/NoteDetail";
|
||||||
import NoteForm from "./equipment/NoteForm";
|
import NoteForm from "./equipment/NoteForm";
|
||||||
|
import StaffList from "./settings/StaffList";
|
||||||
|
import StaffDetail from "./settings/StaffDetail";
|
||||||
|
import StaffForm from "./settings/StaffForm";
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -37,6 +40,52 @@ function ProtectedRoute({ children }) {
|
|||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PermissionGate({ section, action = "view", children }) {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
|
if (!hasPermission(section, action)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<div className="rounded-lg border p-8 text-center max-w-md" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||||
|
<svg className="w-12 h-12 mx-auto mb-4" style={{ color: "var(--text-muted)" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-lg font-semibold mb-2" style={{ color: "var(--text-heading)" }}>Access Denied</h2>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
You don't have permission to access this feature.
|
||||||
|
Please contact an administrator if you need access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleGate({ roles, children }) {
|
||||||
|
const { hasRole } = useAuth();
|
||||||
|
|
||||||
|
if (!hasRole(...roles)) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20">
|
||||||
|
<div className="rounded-lg border p-8 text-center max-w-md" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||||
|
<svg className="w-12 h-12 mx-auto mb-4" style={{ color: "var(--text-muted)" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-lg font-semibold mb-2" style={{ color: "var(--text-heading)" }}>Access Denied</h2>
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
You don't have permission to access this feature.
|
||||||
|
Please contact an administrator if you need access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
return (
|
return (
|
||||||
@@ -62,26 +111,43 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="melodies" element={<MelodyList />} />
|
|
||||||
<Route path="melodies/settings" element={<MelodySettings />} />
|
{/* Melodies */}
|
||||||
<Route path="melodies/new" element={<MelodyForm />} />
|
<Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></PermissionGate>} />
|
||||||
<Route path="melodies/:id" element={<MelodyDetail />} />
|
<Route path="melodies/settings" element={<PermissionGate section="melodies"><MelodySettings /></PermissionGate>} />
|
||||||
<Route path="melodies/:id/edit" element={<MelodyForm />} />
|
<Route path="melodies/new" element={<PermissionGate section="melodies" action="add"><MelodyForm /></PermissionGate>} />
|
||||||
<Route path="devices" element={<DeviceList />} />
|
<Route path="melodies/:id" element={<PermissionGate section="melodies"><MelodyDetail /></PermissionGate>} />
|
||||||
<Route path="devices/new" element={<DeviceForm />} />
|
<Route path="melodies/:id/edit" element={<PermissionGate section="melodies" action="edit"><MelodyForm /></PermissionGate>} />
|
||||||
<Route path="devices/:id" element={<DeviceDetail />} />
|
|
||||||
<Route path="devices/:id/edit" element={<DeviceForm />} />
|
{/* Devices */}
|
||||||
<Route path="users" element={<UserList />} />
|
<Route path="devices" element={<PermissionGate section="devices"><DeviceList /></PermissionGate>} />
|
||||||
<Route path="users/new" element={<UserForm />} />
|
<Route path="devices/new" element={<PermissionGate section="devices" action="add"><DeviceForm /></PermissionGate>} />
|
||||||
<Route path="users/:id" element={<UserDetail />} />
|
<Route path="devices/:id" element={<PermissionGate section="devices"><DeviceDetail /></PermissionGate>} />
|
||||||
<Route path="users/:id/edit" element={<UserForm />} />
|
<Route path="devices/:id/edit" element={<PermissionGate section="devices" action="edit"><DeviceForm /></PermissionGate>} />
|
||||||
<Route path="mqtt" element={<MqttDashboard />} />
|
|
||||||
<Route path="mqtt/commands" element={<CommandPanel />} />
|
{/* App Users */}
|
||||||
<Route path="mqtt/logs" element={<LogViewer />} />
|
<Route path="users" element={<PermissionGate section="app_users"><UserList /></PermissionGate>} />
|
||||||
<Route path="equipment/notes" element={<NoteList />} />
|
<Route path="users/new" element={<PermissionGate section="app_users" action="add"><UserForm /></PermissionGate>} />
|
||||||
<Route path="equipment/notes/new" element={<NoteForm />} />
|
<Route path="users/:id" element={<PermissionGate section="app_users"><UserDetail /></PermissionGate>} />
|
||||||
<Route path="equipment/notes/:id" element={<NoteDetail />} />
|
<Route path="users/:id/edit" element={<PermissionGate section="app_users" action="edit"><UserForm /></PermissionGate>} />
|
||||||
<Route path="equipment/notes/:id/edit" element={<NoteForm />} />
|
|
||||||
|
{/* MQTT */}
|
||||||
|
<Route path="mqtt" element={<PermissionGate section="mqtt"><MqttDashboard /></PermissionGate>} />
|
||||||
|
<Route path="mqtt/commands" element={<PermissionGate section="mqtt"><CommandPanel /></PermissionGate>} />
|
||||||
|
<Route path="mqtt/logs" element={<PermissionGate section="mqtt"><LogViewer /></PermissionGate>} />
|
||||||
|
|
||||||
|
{/* Equipment Notes */}
|
||||||
|
<Route path="equipment/notes" element={<PermissionGate section="equipment"><NoteList /></PermissionGate>} />
|
||||||
|
<Route path="equipment/notes/new" element={<PermissionGate section="equipment" action="add"><NoteForm /></PermissionGate>} />
|
||||||
|
<Route path="equipment/notes/:id" element={<PermissionGate section="equipment"><NoteDetail /></PermissionGate>} />
|
||||||
|
<Route path="equipment/notes/:id/edit" element={<PermissionGate section="equipment" action="edit"><NoteForm /></PermissionGate>} />
|
||||||
|
|
||||||
|
{/* Settings - Staff Management */}
|
||||||
|
<Route path="settings/staff" element={<RoleGate roles={["sysadmin", "admin"]}><StaffList /></RoleGate>} />
|
||||||
|
<Route path="settings/staff/new" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
|
||||||
|
<Route path="settings/staff/:id" element={<RoleGate roles={["sysadmin", "admin"]}><StaffDetail /></RoleGate>} />
|
||||||
|
<Route path="settings/staff/:id/edit" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -36,9 +36,26 @@ export function AuthProvider({ children }) {
|
|||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
const data = await api.post("/auth/login", { email, password });
|
const data = await api.post("/auth/login", { email, password });
|
||||||
localStorage.setItem("access_token", data.access_token);
|
localStorage.setItem("access_token", data.access_token);
|
||||||
const userInfo = { name: data.name, role: data.role };
|
const userInfo = {
|
||||||
|
name: data.name,
|
||||||
|
role: data.role,
|
||||||
|
permissions: data.permissions || null,
|
||||||
|
};
|
||||||
localStorage.setItem("user", JSON.stringify(userInfo));
|
localStorage.setItem("user", JSON.stringify(userInfo));
|
||||||
setUser(userInfo);
|
setUser(userInfo);
|
||||||
|
|
||||||
|
// Fetch full profile from /staff/me for up-to-date permissions
|
||||||
|
try {
|
||||||
|
const me = await api.get("/staff/me");
|
||||||
|
if (me.permissions) {
|
||||||
|
const updated = { ...userInfo, permissions: me.permissions };
|
||||||
|
localStorage.setItem("user", JSON.stringify(updated));
|
||||||
|
setUser(updated);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical, permissions from login response are used
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,12 +67,30 @@ export function AuthProvider({ children }) {
|
|||||||
|
|
||||||
const hasRole = (...roles) => {
|
const hasRole = (...roles) => {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
if (user.role === "superadmin") return true;
|
if (user.role === "sysadmin") return true;
|
||||||
return roles.includes(user.role);
|
return roles.includes(user.role);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasPermission = (section, action) => {
|
||||||
|
if (!user) return false;
|
||||||
|
// sysadmin and admin have full access
|
||||||
|
if (user.role === "sysadmin" || user.role === "admin") return true;
|
||||||
|
|
||||||
|
const perms = user.permissions;
|
||||||
|
if (!perms) return false;
|
||||||
|
|
||||||
|
// MQTT is a global flag
|
||||||
|
if (section === "mqtt") {
|
||||||
|
return !!perms.mqtt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionPerms = perms[section];
|
||||||
|
if (!sectionPerms) return false;
|
||||||
|
return !!sectionPerms[action];
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, login, logout, loading, hasRole }}>
|
<AuthContext.Provider value={{ user, login, logout, loading, hasRole, hasPermission }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ function formatCoordinates(coords) {
|
|||||||
export default function DeviceDetail() {
|
export default function DeviceDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasRole } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasRole("superadmin", "device_manager");
|
const canEdit = hasPermission("devices", "edit");
|
||||||
|
|
||||||
const [device, setDevice] = useState(null);
|
const [device, setDevice] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -298,35 +298,71 @@ export default function DeviceDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-6">
|
||||||
{/* Left column */}
|
{/* Basic Information */}
|
||||||
<div className="space-y-6">
|
<SectionCard title="Basic Information">
|
||||||
{/* Basic Information */}
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<SectionCard title="Basic Information">
|
<Field label="Serial Number">
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<span className="font-mono">{device.device_id}</span>
|
||||||
<Field label="Serial Number">
|
</Field>
|
||||||
<span className="font-mono">{device.device_id}</span>
|
<Field label="Status">
|
||||||
</Field>
|
<BoolBadge value={isOnline} yesLabel="Online" noLabel="Offline" />
|
||||||
<Field label="Status">
|
{mqttStatus && (
|
||||||
<BoolBadge value={isOnline} yesLabel="Online" noLabel="Offline" />
|
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
{mqttStatus && (
|
(MQTT {mqttStatus.seconds_since_heartbeat}s ago)
|
||||||
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}>
|
</span>
|
||||||
(MQTT {mqttStatus.seconds_since_heartbeat}s ago)
|
)}
|
||||||
</span>
|
</Field>
|
||||||
)}
|
<Field label="Document ID">
|
||||||
</Field>
|
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.id}</span>
|
||||||
<Field label="Events On">
|
</Field>
|
||||||
<BoolBadge value={device.events_on} />
|
</dl>
|
||||||
</Field>
|
</SectionCard>
|
||||||
<Field label="Document ID">
|
|
||||||
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.id}</span>
|
|
||||||
</Field>
|
|
||||||
</dl>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* Location */}
|
{/* Misc */}
|
||||||
<SectionCard title="Location">
|
<SectionCard title="Misc">
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<Field label="Automated Events"><BoolBadge value={device.events_on} yesLabel="ON" noLabel="OFF" /></Field>
|
||||||
|
<Field label="Device Locale"><span className="capitalize">{attr.deviceLocale || "-"}</span></Field>
|
||||||
|
<Field label="WebSocket URL">{device.websocket_url}</Field>
|
||||||
|
<Field label="Has Assistant"><BoolBadge value={attr.hasAssistant} /></Field>
|
||||||
|
<div className="col-span-2 md:col-span-3">
|
||||||
|
<Field label="Church Assistant URL">{device.churchAssistantURL}</Field>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Subscription */}
|
||||||
|
<SectionCard title="Subscription">
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<Field label="Tier">
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
||||||
|
{sub.subscrTier}
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="Start Date">{formatDate(sub.subscrStart)}</Field>
|
||||||
|
<Field label="Duration">{sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"}</Field>
|
||||||
|
<Field label="Expiration Date">{subscrEnd ? formatDateNice(subscrEnd) : "-"}</Field>
|
||||||
|
<Field label="Time Left">
|
||||||
|
{subscrDaysLeft === null ? "-" : subscrDaysLeft <= 0 ? (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
|
||||||
|
Subscription Expired
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||||
|
{subscrDaysLeft} days left
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
<Field label="Max Users">{sub.maxUsers}</Field>
|
||||||
|
<Field label="Max Outputs">{sub.maxOutputs}</Field>
|
||||||
|
</dl>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<SectionCard title="Location">
|
||||||
|
<div className={coords ? "grid grid-cols-1 md:grid-cols-2 gap-4" : ""}>
|
||||||
|
<dl className="space-y-4">
|
||||||
<Field label="Location">{device.device_location}</Field>
|
<Field label="Location">{device.device_location}</Field>
|
||||||
<Field label="Coordinates">
|
<Field label="Coordinates">
|
||||||
<div>
|
<div>
|
||||||
@@ -350,8 +386,8 @@ export default function DeviceDetail() {
|
|||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
{coords && (
|
{coords && (
|
||||||
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)", height: 200 }}>
|
<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%" }} scrollWheelZoom={false}>
|
<MapContainer center={[coords.lat, coords.lng]} zoom={13} style={{ height: "100%", width: "100%", minHeight: 300 }} scrollWheelZoom={false}>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
@@ -360,36 +396,118 @@ export default function DeviceDetail() {
|
|||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{/* Subscription */}
|
{/* Warranty, Maintenance & Statistics */}
|
||||||
<SectionCard title="Subscription">
|
<SectionCard title="Warranty, Maintenance & Statistics">
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
{/* Subsection 1: Warranty */}
|
||||||
<Field label="Tier">
|
<Subsection title="Warranty Information" isFirst>
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full capitalize" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
<Field label="Warranty Status">
|
||||||
{sub.subscrTier}
|
{warrantyDaysLeft !== null ? (
|
||||||
</span>
|
warrantyDaysLeft > 0 ? (
|
||||||
</Field>
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Active</span>
|
||||||
<Field label="Start Date">{formatDate(sub.subscrStart)}</Field>
|
|
||||||
<Field label="Duration">{sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"}</Field>
|
|
||||||
<Field label="Expiration Date">{subscrEnd ? formatDateNice(subscrEnd) : "-"}</Field>
|
|
||||||
<Field label="Time Left">
|
|
||||||
{subscrDaysLeft === null ? "-" : subscrDaysLeft <= 0 ? (
|
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
|
|
||||||
Subscription Expired
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>Expired</span>
|
||||||
{subscrDaysLeft} days left
|
)
|
||||||
</span>
|
) : (
|
||||||
)}
|
<BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />
|
||||||
</Field>
|
)}
|
||||||
<Field label="Max Users">{sub.maxUsers}</Field>
|
</Field>
|
||||||
<Field label="Max Outputs">{sub.maxOutputs}</Field>
|
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
|
||||||
</dl>
|
<Field label="Warranty Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
|
||||||
</SectionCard>
|
<Field label="Expiration Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
|
||||||
|
<Field label="Remaining">
|
||||||
|
{warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? (
|
||||||
|
<span style={{ color: "var(--danger-text)" }}>Expired</span>
|
||||||
|
) : (
|
||||||
|
`${warrantyDaysLeft} days`
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</Subsection>
|
||||||
|
|
||||||
{/* Device Settings (combined) */}
|
{/* Subsection 2: Maintenance */}
|
||||||
|
<Subsection title="Maintenance">
|
||||||
|
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
|
||||||
|
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
|
||||||
|
<Field label="Next Scheduled">
|
||||||
|
{nextMaintenance ? (
|
||||||
|
<span>
|
||||||
|
{formatDateNice(nextMaintenance)}
|
||||||
|
{maintenanceDaysLeft !== null && (
|
||||||
|
<span className="ml-1 text-xs" style={{ color: maintenanceDaysLeft <= 0 ? "var(--danger-text)" : "var(--text-muted)" }}>
|
||||||
|
({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : "-"}
|
||||||
|
</Field>
|
||||||
|
</Subsection>
|
||||||
|
|
||||||
|
{/* Subsection 3: Statistics */}
|
||||||
|
<Subsection title="Statistics">
|
||||||
|
<Field label="Total Playbacks">{stats.totalPlaybacks}</Field>
|
||||||
|
<Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field>
|
||||||
|
<Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field>
|
||||||
|
<Field label="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
|
||||||
|
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
|
||||||
|
{stats.perBellStrikes?.length > 0 && (
|
||||||
|
<div className="col-span-2 md:col-span-3">
|
||||||
|
<Field label="Per Bell Strikes">
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
|
||||||
|
<span key={i} className="px-2 py-1 text-xs rounded-md border" style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
|
||||||
|
Bell {i + 1}: {count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Subsection>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Users */}
|
||||||
|
<SectionCard title={`App Users (${deviceUsers.length})`}>
|
||||||
|
{usersLoading ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users...</p>
|
||||||
|
) : deviceUsers.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned to this device.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deviceUsers.map((user, i) => (
|
||||||
|
<div
|
||||||
|
key={user.user_id || i}
|
||||||
|
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
|
||||||
|
onClick={() => user.user_id && navigate(`/users/${user.user_id}`)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{user.display_name || user.email || "Unknown User"}
|
||||||
|
</p>
|
||||||
|
{user.email && user.display_name && (
|
||||||
|
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{user.email}</p>
|
||||||
|
)}
|
||||||
|
{user.user_id && (
|
||||||
|
<p className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{user.user_id}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{user.role && (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0 ml-2" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Device Settings — spans 2 columns on ultrawide */}
|
||||||
|
<div className="2xl:col-span-2">
|
||||||
<SectionCard title="Device Settings">
|
<SectionCard title="Device Settings">
|
||||||
{/* Subsection 1: Basic Attributes */}
|
{/* Subsection 1: Basic Attributes */}
|
||||||
<Subsection title="Basic Attributes" isFirst>
|
<Subsection title="Basic Attributes" isFirst>
|
||||||
@@ -456,130 +574,8 @@ export default function DeviceDetail() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column */}
|
{/* Equipment Notes */}
|
||||||
<div className="space-y-6">
|
<NotesPanel deviceId={id} />
|
||||||
{/* Misc */}
|
|
||||||
<SectionCard title="Misc">
|
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
<Field label="Device Locale"><span className="capitalize">{attr.deviceLocale || "-"}</span></Field>
|
|
||||||
<Field label="WebSocket URL">{device.websocket_url}</Field>
|
|
||||||
<Field label="Has Assistant"><BoolBadge value={attr.hasAssistant} /></Field>
|
|
||||||
<div className="col-span-2 md:col-span-3">
|
|
||||||
<Field label="Church Assistant URL">{device.churchAssistantURL}</Field>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* Warranty & Maintenance & Statistics */}
|
|
||||||
<SectionCard title="Warranty, Maintenance & Statistics">
|
|
||||||
{/* Subsection 1: Warranty */}
|
|
||||||
<Subsection title="Warranty Information" isFirst>
|
|
||||||
<Field label="Warranty Status">
|
|
||||||
{warrantyDaysLeft !== null ? (
|
|
||||||
warrantyDaysLeft > 0 ? (
|
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Active</span>
|
|
||||||
) : (
|
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>Expired</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
|
|
||||||
<Field label="Warranty Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
|
|
||||||
<Field label="Expiration Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
|
|
||||||
<Field label="Remaining">
|
|
||||||
{warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? (
|
|
||||||
<span style={{ color: "var(--danger-text)" }}>Expired</span>
|
|
||||||
) : (
|
|
||||||
`${warrantyDaysLeft} days`
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
</Subsection>
|
|
||||||
|
|
||||||
{/* Subsection 2: Maintenance */}
|
|
||||||
<Subsection title="Maintenance">
|
|
||||||
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
|
|
||||||
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
|
|
||||||
<Field label="Next Scheduled">
|
|
||||||
{nextMaintenance ? (
|
|
||||||
<span>
|
|
||||||
{formatDateNice(nextMaintenance)}
|
|
||||||
{maintenanceDaysLeft !== null && (
|
|
||||||
<span className="ml-1 text-xs" style={{ color: maintenanceDaysLeft <= 0 ? "var(--danger-text)" : "var(--text-muted)" }}>
|
|
||||||
({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : "-"}
|
|
||||||
</Field>
|
|
||||||
</Subsection>
|
|
||||||
|
|
||||||
{/* Subsection 3: Statistics */}
|
|
||||||
<Subsection title="Statistics">
|
|
||||||
<Field label="Total Playbacks">{stats.totalPlaybacks}</Field>
|
|
||||||
<Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field>
|
|
||||||
<Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field>
|
|
||||||
<Field label="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
|
|
||||||
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
|
|
||||||
{stats.perBellStrikes?.length > 0 && (
|
|
||||||
<div className="col-span-2 md:col-span-3">
|
|
||||||
<Field label="Per Bell Strikes">
|
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
|
||||||
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
|
|
||||||
<span key={i} className="px-2 py-1 text-xs rounded-md border" style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
|
|
||||||
Bell {i + 1}: {count}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Subsection>
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* Users */}
|
|
||||||
<SectionCard title={`Users (${deviceUsers.length})`}>
|
|
||||||
{usersLoading ? (
|
|
||||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users...</p>
|
|
||||||
) : deviceUsers.length === 0 ? (
|
|
||||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned to this device.</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{deviceUsers.map((user, i) => (
|
|
||||||
<div
|
|
||||||
key={user.user_id || i}
|
|
||||||
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors"
|
|
||||||
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
|
|
||||||
onClick={() => user.user_id && navigate(`/users/${user.user_id}`)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
|
|
||||||
{user.display_name || user.email || "Unknown User"}
|
|
||||||
</p>
|
|
||||||
{user.email && user.display_name && (
|
|
||||||
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{user.email}</p>
|
|
||||||
)}
|
|
||||||
{user.user_id && (
|
|
||||||
<p className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{user.user_id}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{user.role && (
|
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0 ml-2" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
|
||||||
{user.role}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
{/* Equipment Notes */}
|
|
||||||
<NotesPanel deviceId={id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -108,10 +108,11 @@ export default function DeviceList() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
|
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
|
||||||
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
||||||
|
const [mqttStatusMap, setMqttStatusMap] = useState({});
|
||||||
const columnPickerRef = useRef(null);
|
const columnPickerRef = useRef(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasRole } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasRole("superadmin", "device_manager");
|
const canEdit = hasPermission("devices", "edit");
|
||||||
|
|
||||||
// Close column picker on outside click
|
// Close column picker on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -136,8 +137,20 @@ export default function DeviceList() {
|
|||||||
if (onlineFilter === "false") params.set("online", "false");
|
if (onlineFilter === "false") params.set("online", "false");
|
||||||
if (tierFilter) params.set("tier", tierFilter);
|
if (tierFilter) params.set("tier", tierFilter);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const data = await api.get(`/devices${qs ? `?${qs}` : ""}`);
|
const [data, mqttData] = await Promise.all([
|
||||||
|
api.get(`/devices${qs ? `?${qs}` : ""}`),
|
||||||
|
api.get("/mqtt/status").catch(() => null),
|
||||||
|
]);
|
||||||
setDevices(data.devices);
|
setDevices(data.devices);
|
||||||
|
|
||||||
|
// Build MQTT status lookup by device serial
|
||||||
|
if (mqttData?.devices) {
|
||||||
|
const map = {};
|
||||||
|
for (const s of mqttData.devices) {
|
||||||
|
map[s.device_serial] = s;
|
||||||
|
}
|
||||||
|
setMqttStatusMap(map);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -182,14 +195,17 @@ export default function DeviceList() {
|
|||||||
const stats = device.device_stats || {};
|
const stats = device.device_stats || {};
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "status":
|
case "status": {
|
||||||
|
const mqtt = mqttStatusMap[device.device_id];
|
||||||
|
const isOnline = mqtt ? mqtt.online : device.is_Online;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-block w-2.5 h-2.5 rounded-full ${device.is_Online ? "bg-green-500" : ""}`}
|
className={`inline-block w-2.5 h-2.5 rounded-full ${isOnline ? "bg-green-500" : ""}`}
|
||||||
style={!device.is_Online ? { backgroundColor: "var(--border-primary)" } : undefined}
|
style={!isOnline ? { backgroundColor: "var(--border-primary)" } : undefined}
|
||||||
title={device.is_Online ? "Online" : "Offline"}
|
title={isOnline ? "Online" : "Offline"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case "name":
|
case "name":
|
||||||
return (
|
return (
|
||||||
<span className="font-medium" style={{ color: "var(--text-heading)" }}>
|
<span className="font-medium" style={{ color: "var(--text-heading)" }}>
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ function Field({ label, children }) {
|
|||||||
export default function NoteDetail() {
|
export default function NoteDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasRole } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasRole("superadmin", "device_manager");
|
const canEdit = hasPermission("equipment", "edit");
|
||||||
|
|
||||||
const [note, setNote] = useState(null);
|
const [note, setNote] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export default function NoteList() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
const [hoveredRow, setHoveredRow] = useState(null);
|
const [hoveredRow, setHoveredRow] = useState(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasRole } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasRole("superadmin", "device_manager");
|
const canEdit = hasPermission("equipment", "edit");
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const deviceIdFilter = searchParams.get("device_id") || "";
|
const deviceIdFilter = searchParams.get("device_id") || "";
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export default function NotesPanel({ deviceId, userId }) {
|
|||||||
const [category, setCategory] = useState("general");
|
const [category, setCategory] = useState("general");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasRole } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasRole("superadmin", "device_manager");
|
const canEdit = hasPermission("equipment", "edit");
|
||||||
|
|
||||||
const fetchNotes = async () => {
|
const fetchNotes = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
@@ -3,46 +3,52 @@ import { NavLink, useLocation } from "react-router-dom";
|
|||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/", label: "Dashboard", roles: null },
|
{ to: "/", label: "Dashboard", permission: null },
|
||||||
{
|
{
|
||||||
label: "Melodies",
|
label: "Melodies",
|
||||||
roles: ["superadmin", "melody_editor", "viewer"],
|
permission: "melodies",
|
||||||
children: [
|
children: [
|
||||||
{ to: "/melodies", label: "Editor" },
|
{ to: "/melodies", label: "Editor" },
|
||||||
{ to: "/melodies/settings", label: "Settings" },
|
{ to: "/melodies/settings", label: "Settings" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ to: "/devices", label: "Devices", roles: ["superadmin", "device_manager", "viewer"] },
|
{ to: "/devices", label: "Devices", permission: "devices" },
|
||||||
{ to: "/users", label: "Users", roles: ["superadmin", "user_manager", "viewer"] },
|
{ to: "/users", label: "App Users", permission: "app_users" },
|
||||||
{
|
{
|
||||||
label: "MQTT",
|
label: "MQTT",
|
||||||
roles: ["superadmin", "device_manager", "viewer"],
|
permission: "mqtt",
|
||||||
children: [
|
children: [
|
||||||
{ to: "/mqtt", label: "Dashboard" },
|
{ to: "/mqtt", label: "Dashboard" },
|
||||||
{ to: "/mqtt/commands", label: "Commands" },
|
{ to: "/mqtt/commands", label: "Commands" },
|
||||||
{ to: "/mqtt/logs", label: "Logs" },
|
{ to: "/mqtt/logs", label: "Logs" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ to: "/equipment/notes", label: "Equipment Notes", roles: ["superadmin", "device_manager", "viewer"] },
|
{ to: "/equipment/notes", label: "Equipment Notes", permission: "equipment" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const linkClass = (isActive) =>
|
const linkClass = (isActive, locked) =>
|
||||||
`block px-3 py-2 rounded-md text-sm transition-colors ${
|
`block px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
isActive
|
locked
|
||||||
? "bg-[var(--accent)] text-[var(--bg-primary)] font-medium"
|
? "opacity-40 cursor-not-allowed"
|
||||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
|
: 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() {
|
export default function Sidebar() {
|
||||||
const { hasRole } = useAuth();
|
const { hasPermission, hasRole } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const visibleItems = navItems.filter(
|
const canViewSection = (permission) => {
|
||||||
(item) => item.roles === null || hasRole(...item.roles)
|
if (!permission) return true;
|
||||||
);
|
return hasPermission(permission, "view");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Settings visible only to sysadmin and admin
|
||||||
|
const canManageStaff = hasRole("sysadmin", "admin");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 min-h-screen p-4 border-r" style={{ backgroundColor: "var(--bg-sidebar)", borderColor: "var(--border-primary)" }}>
|
<aside className="w-56 min-h-screen p-4 border-r flex flex-col" style={{ backgroundColor: "var(--bg-sidebar)", borderColor: "var(--border-primary)" }}>
|
||||||
<div className="mb-8 px-2">
|
<div className="mb-8 px-2">
|
||||||
<img
|
<img
|
||||||
src="/logo-dark.png"
|
src="/logo-dark.png"
|
||||||
@@ -50,32 +56,57 @@ export default function Sidebar() {
|
|||||||
className="h-10 w-auto"
|
className="h-10 w-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<nav className="space-y-1">
|
<nav className="space-y-1 flex-1">
|
||||||
{visibleItems.map((item) =>
|
{navItems.map((item) => {
|
||||||
item.children ? (
|
const hasAccess = canViewSection(item.permission);
|
||||||
|
return item.children ? (
|
||||||
<CollapsibleGroup
|
<CollapsibleGroup
|
||||||
key={item.label}
|
key={item.label}
|
||||||
label={item.label}
|
label={item.label}
|
||||||
children={item.children}
|
children={item.children}
|
||||||
currentPath={location.pathname}
|
currentPath={location.pathname}
|
||||||
|
locked={!hasAccess}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={hasAccess ? item.to : "#"}
|
||||||
end={item.to === "/"}
|
end={item.to === "/"}
|
||||||
className={({ isActive }) => linkClass(isActive)}
|
className={({ isActive }) => linkClass(isActive && hasAccess, !hasAccess)}
|
||||||
|
onClick={(e) => !hasAccess && e.preventDefault()}
|
||||||
>
|
>
|
||||||
{item.label}
|
<span className="flex items-center gap-2">
|
||||||
|
{item.label}
|
||||||
|
{!hasAccess && (
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)
|
);
|
||||||
)}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Settings section at the bottom */}
|
||||||
|
{canManageStaff && (
|
||||||
|
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<nav className="space-y-1">
|
||||||
|
<CollapsibleGroup
|
||||||
|
label="Settings"
|
||||||
|
children={[
|
||||||
|
{ to: "/settings/staff", label: "Staff" },
|
||||||
|
]}
|
||||||
|
currentPath={location.pathname}
|
||||||
|
/>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CollapsibleGroup({ label, children, currentPath }) {
|
function CollapsibleGroup({ label, children, currentPath, locked = false }) {
|
||||||
const isChildActive = children.some(
|
const isChildActive = children.some(
|
||||||
(child) =>
|
(child) =>
|
||||||
currentPath === child.to ||
|
currentPath === child.to ||
|
||||||
@@ -89,24 +120,35 @@ function CollapsibleGroup({ label, children, currentPath }) {
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(!shouldBeOpen)}
|
onClick={() => !locked && setOpen(!shouldBeOpen)}
|
||||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
|
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
isChildActive
|
locked
|
||||||
? "text-[var(--text-heading)]"
|
? "opacity-40 cursor-not-allowed"
|
||||||
: "text-[var(--text-secondary)] hover:bg-[var(--bg-card-hover)] hover:text-[var(--text-heading)]"
|
: 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">
|
||||||
<svg
|
{label}
|
||||||
className={`w-4 h-4 transition-transform ${shouldBeOpen ? "rotate-90" : ""}`}
|
{locked && (
|
||||||
fill="none"
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
stroke="currentColor"
|
<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" />
|
||||||
viewBox="0 0 24 24"
|
</svg>
|
||||||
>
|
)}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
</span>
|
||||||
</svg>
|
{!locked && (
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 transition-transform ${shouldBeOpen ? "rotate-90" : ""}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{shouldBeOpen && (
|
{!locked && shouldBeOpen && (
|
||||||
<div className="ml-3 mt-1 space-y-1">
|
<div className="ml-3 mt-1 space-y-1">
|
||||||
{children.map((child) => (
|
{children.map((child) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ function Field({ label, children }) {
|
|||||||
export default function MelodyDetail() {
|
export default function MelodyDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasRole } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasRole("superadmin", "melody_editor");
|
const canEdit = hasPermission("melodies", "edit");
|
||||||
|
|
||||||
const [melody, setMelody] = useState(null);
|
const [melody, setMelody] = useState(null);
|
||||||
const [files, setFiles] = useState({});
|
const [files, setFiles] = useState({});
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export default function MelodyList() {
|
|||||||
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
||||||
const columnPickerRef = useRef(null);
|
const columnPickerRef = useRef(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasRole } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasRole("superadmin", "melody_editor");
|
const canEdit = hasPermission("melodies", "edit");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/settings/melody").then((ms) => {
|
api.get("/settings/melody").then((ms) => {
|
||||||
|
|||||||
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() {
|
export default function UserDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasRole } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasRole("superadmin", "user_manager");
|
const canEdit = hasPermission("app_users", "edit");
|
||||||
|
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [devices, setDevices] = useState([]);
|
const [devices, setDevices] = useState([]);
|
||||||
@@ -165,7 +165,7 @@ export default function UserDetail() {
|
|||||||
className="text-sm hover:underline mb-2 inline-block"
|
className="text-sm hover:underline mb-2 inline-block"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
← Back to Users
|
← Back to App Users
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1
|
<h1
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export default function UserList() {
|
|||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
const [hoveredRow, setHoveredRow] = useState(null);
|
const [hoveredRow, setHoveredRow] = useState(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasRole } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasRole("superadmin", "user_manager");
|
const canEdit = hasPermission("app_users", "edit");
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -57,7 +57,7 @@ export default function UserList() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Users</h1>
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>App Users</h1>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/users/new")}
|
onClick={() => navigate("/users/new")}
|
||||||
|
|||||||
Reference in New Issue
Block a user