Compare commits

...

2 Commits

Author SHA1 Message Date
1022b7e5f1 fix: remove duplicate port mapping 8001:5174 from frontend service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:22:14 +03:00
024ba88470 fix: route manufacturing audit logs to shared Postgres audit log
- Manufacturing router now uses shared/audit.log_action (Postgres) instead
  of the separate manufacturing/audit.py (SQLite mfg_audit_log), so all
  manufacturing events appear in the Log Viewer
- Added log_action calls to 5 previously unlogged endpoints: lifecycle
  patch, lifecycle create, lifecycle delete, flash asset upload, flash
  asset note
- Removed the now-redundant /manufacturing/audit-log endpoint
- Log Viewer restricted to sysadmin only: backend uses require_sysadmin
  (was require_admin_or_above), frontend adds role guard on the page
- Fixed Action badge column clipping: table-layout auto + whiteSpace nowrap
  so the column sizes to fit the widest badge (Status Change)
- Added device_batch entity type to Log Viewer entity labels and filters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:21:39 +03:00
4 changed files with 173 additions and 86 deletions

View File

@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from database.postgres import get_pg_session from database.postgres import get_pg_session
from shared.orm import AuditLog from shared.orm import AuditLog
from auth.dependencies import require_admin_or_above from auth.dependencies import require_sysadmin
from auth.models import TokenPayload from auth.models import TokenPayload
router = APIRouter(prefix="/api/audit-log", tags=["audit-log"]) router = APIRouter(prefix="/api/audit-log", tags=["audit-log"])
@@ -26,7 +26,7 @@ async def list_audit_log(
to_date: Optional[datetime] = Query(None), to_date: Optional[datetime] = Query(None),
limit: int = Query(_DEFAULT_LIMIT, ge=1, le=_MAX_LIMIT), limit: int = Query(_DEFAULT_LIMIT, ge=1, le=_MAX_LIMIT),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
_user: TokenPayload = Depends(require_admin_or_above), _user: TokenPayload = Depends(require_sysadmin),
db: AsyncSession = Depends(get_pg_session), db: AsyncSession = Depends(get_pg_session),
): ):
filters = [] filters = []

View File

@@ -3,6 +3,7 @@ from fastapi.responses import Response
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from auth.models import TokenPayload from auth.models import TokenPayload
from auth.dependencies import require_permission from auth.dependencies import require_permission
@@ -13,9 +14,10 @@ from manufacturing.models import (
ManufacturingStats, ManufacturingStats,
) )
from manufacturing import service from manufacturing import service
from manufacturing import audit from shared.audit import log_action
from shared.exceptions import NotFoundError from shared.exceptions import NotFoundError
from shared.firebase import get_db as get_firestore from shared.firebase import get_db as get_firestore
from database.postgres import get_pg_session
class LifecycleEntryPatch(BaseModel): class LifecycleEntryPatch(BaseModel):
@@ -43,26 +45,21 @@ def get_stats(
return service.get_stats() return service.get_stats()
@router.get("/audit-log")
async def get_audit_log(
limit: int = Query(20, ge=1, le=100),
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
):
entries = await audit.get_recent(limit=limit)
return {"entries": entries}
@router.post("/batch", response_model=BatchResponse, status_code=201) @router.post("/batch", response_model=BatchResponse, status_code=201)
async def create_batch( async def create_batch(
body: BatchCreate, body: BatchCreate,
user: TokenPayload = Depends(require_permission("manufacturing", "add")), user: TokenPayload = Depends(require_permission("manufacturing", "add")),
db: AsyncSession = Depends(get_pg_session),
): ):
result = service.create_batch(body) result = service.create_batch(body)
await audit.log_action( await log_action(
admin_user=user.email, db, user.sub, user.email,
action="batch_created", action="CREATE",
detail={ entity_type="device_batch",
"batch_id": result.batch_id, entity_id=result.batch_id,
entity_label=f"Batch {result.batch_id} ({result.board_type}, qty {len(result.serial_numbers)})",
meta={
"board_type": result.board_type, "board_type": result.board_type,
"board_version": result.board_version, "board_version": result.board_version,
"quantity": len(result.serial_numbers), "quantity": len(result.serial_numbers),
@@ -137,6 +134,7 @@ async def update_status(
sn: str, sn: str,
body: DeviceStatusUpdate, body: DeviceStatusUpdate,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")), user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
): ):
# Guard: claimed requires at least one user in user_list # Guard: claimed requires at least one user in user_list
# (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually) # (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually)
@@ -167,11 +165,13 @@ async def update_status(
) )
result = service.update_device_status(sn, body, set_by=user.email) result = service.update_device_status(sn, body, set_by=user.email)
await audit.log_action( await log_action(
admin_user=user.email, db, user.sub, user.email,
action="status_updated", action="STATUS_CHANGE",
serial_number=sn, entity_type="device",
detail={"status": body.status.value, "note": body.note}, entity_id=sn,
entity_label=sn,
meta={"status": body.status.value, "note": body.note},
) )
return result return result
@@ -181,10 +181,11 @@ async def patch_lifecycle_entry(
sn: str, sn: str,
body: LifecycleEntryPatch, body: LifecycleEntryPatch,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")), user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Edit the date and/or note of a lifecycle history entry by index.""" """Edit the date and/or note of a lifecycle history entry by index."""
db = get_firestore() fs = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if not docs: if not docs:
raise HTTPException(status_code=404, detail="Device not found") raise HTTPException(status_code=404, detail="Device not found")
doc_ref = docs[0].reference doc_ref = docs[0].reference
@@ -198,7 +199,16 @@ async def patch_lifecycle_entry(
history[body.index]["note"] = body.note history[body.index]["note"] = body.note
doc_ref.update({"lifecycle_history": history}) doc_ref.update({"lifecycle_history": history})
from manufacturing.service import _doc_to_inventory_item from manufacturing.service import _doc_to_inventory_item
return _doc_to_inventory_item(doc_ref.get()) result = _doc_to_inventory_item(doc_ref.get())
await log_action(
db, user.sub, user.email,
action="UPDATE",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"lifecycle_index": body.index, "date": body.date, "note": body.note},
)
return result
@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=200) @router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=200)
@@ -206,6 +216,7 @@ async def create_lifecycle_entry(
sn: str, sn: str,
body: LifecycleEntryCreate, body: LifecycleEntryCreate,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")), user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Upsert a lifecycle history entry for the given status_id. """Upsert a lifecycle history entry for the given status_id.
@@ -214,8 +225,8 @@ async def create_lifecycle_entry(
a status is visited more than once (max one entry per status). a status is visited more than once (max one entry per status).
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
db = get_firestore() fs = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if not docs: if not docs:
raise HTTPException(status_code=404, detail="Device not found") raise HTTPException(status_code=404, detail="Device not found")
doc_ref = docs[0].reference doc_ref = docs[0].reference
@@ -229,19 +240,28 @@ async def create_lifecycle_entry(
"set_by": user.email, "set_by": user.email,
} }
# Overwrite existing entry for this status if present, else append
existing_idx = next( existing_idx = next(
(i for i, e in enumerate(history) if e.get("status_id") == body.status_id), (i for i, e in enumerate(history) if e.get("status_id") == body.status_id),
None, None,
) )
if existing_idx is not None: is_update = existing_idx is not None
if is_update:
history[existing_idx] = new_entry history[existing_idx] = new_entry
else: else:
history.append(new_entry) history.append(new_entry)
doc_ref.update({"lifecycle_history": history}) doc_ref.update({"lifecycle_history": history})
from manufacturing.service import _doc_to_inventory_item from manufacturing.service import _doc_to_inventory_item
return _doc_to_inventory_item(doc_ref.get()) result = _doc_to_inventory_item(doc_ref.get())
await log_action(
db, user.sub, user.email,
action="UPDATE" if is_update else "CREATE",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"lifecycle_status": body.status_id, "date": new_entry["date"], "note": body.note},
)
return result
@router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem) @router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem)
@@ -249,10 +269,11 @@ async def delete_lifecycle_entry(
sn: str, sn: str,
index: int, index: int,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")), user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Delete a lifecycle history entry by index. Cannot delete the entry for the current status.""" """Delete a lifecycle history entry by index. Cannot delete the entry for the current status."""
db = get_firestore() fs = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) docs = list(fs.collection("devices").where("serial_number", "==", sn).limit(1).stream())
if not docs: if not docs:
raise HTTPException(status_code=404, detail="Device not found") raise HTTPException(status_code=404, detail="Device not found")
doc_ref = docs[0].reference doc_ref = docs[0].reference
@@ -261,12 +282,22 @@ async def delete_lifecycle_entry(
if index < 0 or index >= len(history): if index < 0 or index >= len(history):
raise HTTPException(status_code=400, detail="Invalid lifecycle entry index") raise HTTPException(status_code=400, detail="Invalid lifecycle entry index")
current_status = data.get("mfg_status", "") current_status = data.get("mfg_status", "")
if history[index].get("status_id") == current_status: deleted_entry = history[index]
if deleted_entry.get("status_id") == current_status:
raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.") raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.")
history.pop(index) history.pop(index)
doc_ref.update({"lifecycle_history": history}) doc_ref.update({"lifecycle_history": history})
from manufacturing.service import _doc_to_inventory_item from manufacturing.service import _doc_to_inventory_item
return _doc_to_inventory_item(doc_ref.get()) result = _doc_to_inventory_item(doc_ref.get())
await log_action(
db, user.sub, user.email,
action="DELETE",
entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"lifecycle_status": deleted_entry.get("status_id"), "index": index},
)
return result
@router.get("/devices/{sn}/nvs.bin") @router.get("/devices/{sn}/nvs.bin")
@@ -276,12 +307,16 @@ async def download_nvs(
hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"), hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"),
nvs_schema: Optional[str] = Query(None, description="NVS schema to use: 'legacy' or 'new' (default)"), nvs_schema: Optional[str] = Query(None, description="NVS schema to use: 'legacy' or 'new' (default)"),
user: TokenPayload = Depends(require_permission("manufacturing", "view")), user: TokenPayload = Depends(require_permission("manufacturing", "view")),
db: AsyncSession = Depends(get_pg_session),
): ):
binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override, legacy=(nvs_schema == "legacy")) binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override, legacy=(nvs_schema == "legacy"))
await audit.log_action( await log_action(
admin_user=user.email, db, user.sub, user.email,
action="device_flashed", action="COMMAND",
serial_number=sn, entity_type="device",
entity_id=sn,
entity_label=sn,
meta={"command": "nvs_flash", "hw_type_override": hw_type_override, "nvs_schema": nvs_schema or "new"},
) )
return Response( return Response(
content=binary, content=binary,
@@ -295,16 +330,19 @@ async def assign_device(
sn: str, sn: str,
body: DeviceAssign, body: DeviceAssign,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")), user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
): ):
try: try:
result = service.assign_device(sn, body) result = service.assign_device(sn, body)
except NotFoundError as e: except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
await audit.log_action( await log_action(
admin_user=user.email, db, user.sub, user.email,
action="device_assigned", action="UPDATE",
serial_number=sn, entity_type="device",
detail={"customer_id": body.customer_id}, entity_id=sn,
entity_label=sn,
meta={"customer_id": body.customer_id},
) )
return result return result
@@ -314,6 +352,7 @@ async def delete_device(
sn: str, sn: str,
force: bool = Query(False, description="Required to delete sold/claimed devices"), force: bool = Query(False, description="Required to delete sold/claimed devices"),
user: TokenPayload = Depends(require_permission("manufacturing", "delete")), user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Delete a device. Sold/claimed devices require force=true.""" """Delete a device. Sold/claimed devices require force=true."""
try: try:
@@ -322,11 +361,13 @@ async def delete_device(
raise HTTPException(status_code=404, detail="Device not found") raise HTTPException(status_code=404, detail="Device not found")
except PermissionError as e: except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e)) raise HTTPException(status_code=403, detail=str(e))
await audit.log_action( await log_action(
admin_user=user.email, db, user.sub, user.email,
action="device_deleted", action="DELETE",
serial_number=sn, entity_type="device",
detail={"force": force}, entity_id=sn,
entity_label=sn,
meta={"force": force},
) )
@@ -334,6 +375,7 @@ async def delete_device(
async def send_manufactured_email( async def send_manufactured_email(
sn: str, sn: str,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")), user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Send the 'device manufactured' notification to the assigned customer's email.""" """Send the 'device manufactured' notification to the assigned customer's email."""
db = get_firestore() db = get_firestore()
@@ -361,11 +403,13 @@ async def send_manufactured_email(
device_name=hw_family.replace("_", " ").title(), device_name=hw_family.replace("_", " ").title(),
customer_name=customer_name, customer_name=customer_name,
) )
await audit.log_action( await log_action(
admin_user=user.email, db, user.sub, user.email,
action="email_manufactured_sent", action="COMMAND",
serial_number=sn, entity_type="device",
detail={"recipient": email}, entity_id=sn,
entity_label=sn,
meta={"command": "email_manufactured", "recipient": email},
) )
@@ -373,6 +417,7 @@ async def send_manufactured_email(
async def send_assigned_email( async def send_assigned_email(
sn: str, sn: str,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")), user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Send the 'device assigned / app instructions' email to the assigned user(s).""" """Send the 'device assigned / app instructions' email to the assigned user(s)."""
db = get_firestore() db = get_firestore()
@@ -407,24 +452,30 @@ async def send_assigned_email(
errors.append(str(exc)) errors.append(str(exc))
if errors: if errors:
raise HTTPException(status_code=500, detail=f"Some emails failed: {'; '.join(errors)}") raise HTTPException(status_code=500, detail=f"Some emails failed: {'; '.join(errors)}")
await audit.log_action( await log_action(
admin_user=user.email, db, user.sub, user.email,
action="email_assigned_sent", action="COMMAND",
serial_number=sn, entity_type="device",
detail={"user_count": len(user_list)}, entity_id=sn,
entity_label=sn,
meta={"command": "email_assigned", "user_count": len(user_list)},
) )
@router.delete("/devices", status_code=200) @router.delete("/devices", status_code=200)
async def delete_unprovisioned( async def delete_unprovisioned(
user: TokenPayload = Depends(require_permission("manufacturing", "delete")), user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Delete all devices with status 'manufactured' (never provisioned).""" """Delete all devices with status 'manufactured' (never provisioned)."""
deleted = service.delete_unprovisioned_devices() deleted = service.delete_unprovisioned_devices()
await audit.log_action( await log_action(
admin_user=user.email, db, user.sub, user.email,
action="bulk_delete_unprovisioned", action="DELETE",
detail={"count": len(deleted), "serial_numbers": deleted}, entity_type="device_batch",
entity_id="bulk_unprovisioned",
entity_label=f"Bulk delete unprovisioned ({len(deleted)} devices)",
meta={"count": len(deleted), "serial_numbers": deleted},
) )
return {"deleted": deleted, "count": len(deleted)} return {"deleted": deleted, "count": len(deleted)}
@@ -466,6 +517,7 @@ async def delete_flash_asset(
hw_type: str, hw_type: str,
asset: str, asset: str,
user: TokenPayload = Depends(require_permission("manufacturing", "delete")), user: TokenPayload = Depends(require_permission("manufacturing", "delete")),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Delete a single flash asset file (bootloader.bin or partitions.bin).""" """Delete a single flash asset file (bootloader.bin or partitions.bin)."""
if asset not in VALID_FLASH_ASSETS: if asset not in VALID_FLASH_ASSETS:
@@ -474,10 +526,13 @@ async def delete_flash_asset(
service.delete_flash_asset(hw_type, asset) service.delete_flash_asset(hw_type, asset)
except NotFoundError as e: except NotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
await audit.log_action( await log_action(
admin_user=user.email, db, user.sub, user.email,
action="flash_asset_deleted", action="DELETE",
detail={"hw_type": hw_type, "asset": asset}, entity_type="firmware",
entity_id=f"{hw_type}/{asset}",
entity_label=f"{hw_type} / {asset}",
meta={"hw_type": hw_type, "asset": asset},
) )
@@ -489,7 +544,8 @@ class FlashAssetNoteBody(BaseModel):
async def set_flash_asset_note( async def set_flash_asset_note(
hw_type: str, hw_type: str,
body: FlashAssetNoteBody, body: FlashAssetNoteBody,
_user: TokenPayload = Depends(require_permission("manufacturing", "edit")), user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Save (or overwrite) the note for a hw_type's flash asset set. """Save (or overwrite) the note for a hw_type's flash asset set.
@@ -497,6 +553,14 @@ async def set_flash_asset_note(
Pass an empty string to clear the note. Pass an empty string to clear the note.
""" """
service.set_flash_asset_note(hw_type, body.note) service.set_flash_asset_note(hw_type, body.note)
await log_action(
db, user.sub, user.email,
action="UPDATE",
entity_type="firmware",
entity_id=hw_type,
entity_label=hw_type,
meta={"note": body.note},
)
@router.post("/flash-assets/{hw_type}/{asset}", status_code=204) @router.post("/flash-assets/{hw_type}/{asset}", status_code=204)
@@ -504,7 +568,8 @@ async def upload_flash_asset(
hw_type: str, hw_type: str,
asset: str, asset: str,
file: UploadFile = File(...), file: UploadFile = File(...),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")), user: TokenPayload = Depends(require_permission("manufacturing", "add")),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Upload a bootloader.bin or partitions.bin for a given hw_type. """Upload a bootloader.bin or partitions.bin for a given hw_type.
@@ -512,13 +577,20 @@ async def upload_flash_asset(
and .pio/build/{env}/partitions.bin). Upload them once per hw_type after and .pio/build/{env}/partitions.bin). Upload them once per hw_type after
each PlatformIO build that changes the partition layout. each PlatformIO build that changes the partition layout.
""" """
# hw_type can be a standard board type OR a bespoke UID (any non-empty slug)
if not hw_type or len(hw_type) > 128: if not hw_type or len(hw_type) > 128:
raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.") raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.")
if asset not in VALID_FLASH_ASSETS: if asset not in VALID_FLASH_ASSETS:
raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}") raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}")
data = await file.read() data = await file.read()
service.save_flash_asset(hw_type, asset, data) service.save_flash_asset(hw_type, asset, data)
await log_action(
db, user.sub, user.email,
action="CREATE",
entity_type="firmware",
entity_id=f"{hw_type}/{asset}",
entity_label=f"{hw_type} / {asset}",
meta={"hw_type": hw_type, "asset": asset, "size_bytes": len(data)},
)
@router.get("/devices/{sn}/bootloader.bin") @router.get("/devices/{sn}/bootloader.bin")

View File

@@ -26,7 +26,6 @@ services:
- /app/node_modules - /app/node_modules
ports: ports:
- "5173:5174" - "5173:5174"
- "8001:5174"
networks: networks:
- internal - internal

View File

@@ -35,6 +35,7 @@ const ENTITY_LABELS = {
customer: 'Customer', customer: 'Customer',
order: 'Order', order: 'Order',
device: 'Device', device: 'Device',
device_batch: 'Device Batch',
melody: 'Melody', melody: 'Melody',
product: 'Product', product: 'Product',
staff: 'Staff', staff: 'Staff',
@@ -246,7 +247,7 @@ function LogRow({ entry, isExpanded, onToggle }) {
</td> </td>
{/* Action badge */} {/* Action badge */}
<td className="log-cell"> <td className="log-cell" style={{ whiteSpace: 'nowrap' }}>
<StatusBadge variant={variant}>{label}</StatusBadge> <StatusBadge variant={variant}>{label}</StatusBadge>
</td> </td>
@@ -492,9 +493,7 @@ const INITIAL_FILTERS = {
offset: 0, offset: 0,
} }
export default function LogViewerPage() { function LogViewerContent() {
const { user } = useAuth()
const [entries, setEntries] = useState([]) const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@@ -592,7 +591,7 @@ export default function LogViewerPage() {
.log-table { .log-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
table-layout: fixed; table-layout: auto;
} }
.log-table thead th { .log-table thead th {
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);
@@ -668,7 +667,7 @@ export default function LogViewerPage() {
<tr> <tr>
<th style={{ width: 148 }}>Timestamp</th> <th style={{ width: 148 }}>Timestamp</th>
<th style={{ width: 160 }}>Staff</th> <th style={{ width: 160 }}>Staff</th>
<th style={{ width: 110 }}>Action</th> <th style={{ whiteSpace: 'nowrap' }}>Action</th>
<th>Entity</th> <th>Entity</th>
<th style={{ width: 120 }}>ID</th> <th style={{ width: 120 }}>ID</th>
<th style={{ width: 40 }} /> <th style={{ width: 40 }} />
@@ -752,3 +751,20 @@ export default function LogViewerPage() {
</> </>
) )
} }
export default function LogViewerPage() {
const { hasRole } = useAuth()
if (!hasRole('sysadmin')) {
return (
<div className="page-wrapper">
<PageHeader title="Log Viewer" subtitle="Staff actions and system events across the console" />
<div style={{ padding: 'var(--space-12)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
Access restricted to system administrators.
</div>
</div>
)
}
return <LogViewerContent />
}