From cb1ac023341abe8552cea89217bd878e5f683e39 Mon Sep 17 00:00:00 2001
From: ceej640 <42260127+Ceej640@users.noreply.github.com>
Date: Sat, 30 May 2026 20:40:47 -0400
Subject: [PATCH 1/4] Fix test suite collection and stale expectations
---
gently/harness/conversation.py | 41 +++++++++++++++++++++++++++++
gently/mesh/mesh_service.py | 20 +++++++++++---
tests/test_campaign_coordination.py | 8 ++++++
tests/test_dispim_device_safety.py | 33 +++++++++++++----------
4 files changed, 85 insertions(+), 17 deletions(-)
diff --git a/gently/harness/conversation.py b/gently/harness/conversation.py
index e5de53d9..bfd09684 100644
--- a/gently/harness/conversation.py
+++ b/gently/harness/conversation.py
@@ -17,6 +17,47 @@
logger = logging.getLogger(__name__)
+_TEXT_TOOL_CALL_RE = re.compile(r"\s*(.*?)\s*", re.DOTALL | re.IGNORECASE)
+
+
+def _extract_text_tool_calls(text: str) -> tuple[str, List[Dict[str, Any]]]:
+ """Extract JSON tool calls embedded in text fallback tags.
+
+ Some model/test harness paths may emit a tool request as text instead of
+ structured ``tool_use`` blocks. Keep parsing permissive, but only return
+ well-formed objects that name a tool.
+ """
+ if not text:
+ return text, []
+
+ calls: List[Dict[str, Any]] = []
+
+ def _remove_or_collect(match: re.Match) -> str:
+ try:
+ payload = json.loads(match.group(1).strip())
+ except (TypeError, json.JSONDecodeError):
+ return ""
+ if not isinstance(payload, dict):
+ return ""
+ name = payload.get("name")
+ if not name:
+ return ""
+ tool_input = payload.get("input")
+ if tool_input is None:
+ tool_input = payload.get("arguments", {})
+ if tool_input is None:
+ tool_input = {}
+ calls.append({
+ "name": name,
+ "input": tool_input,
+ "id": payload.get("id"),
+ })
+ return ""
+
+ cleaned = _TEXT_TOOL_CALL_RE.sub(_remove_or_collect, text)
+ return cleaned, calls
+
+
def _extend_tool_calls(out: List[Dict[str, Any]], content_blocks) -> None:
"""Append every tool_use block in content_blocks to out.
diff --git a/gently/mesh/mesh_service.py b/gently/mesh/mesh_service.py
index edac242c..c5cbe5b1 100644
--- a/gently/mesh/mesh_service.py
+++ b/gently/mesh/mesh_service.py
@@ -212,7 +212,7 @@ def _on_peer_discovered(self, data: dict, sender_ip: str, verified: bool = False
# Only fetch status from trusted peers
if trusted:
- asyncio.ensure_future(self._fetch_and_update_peer(peer))
+ self._schedule_status_fetch(peer)
def _on_peer_heartbeat(self, instance_id: str, sender_ip: str, verified: bool = False):
"""Called on subsequent heartbeats from a known peer."""
@@ -228,7 +228,7 @@ def _on_nudge_received(self, peer_id: str, sender_ip: str):
if peer:
peer.last_seen = time.time()
peer.ip_address = sender_ip
- asyncio.ensure_future(self._fetch_and_update_peer(peer))
+ self._schedule_status_fetch(peer)
logger.debug(f"Mesh: nudge from {peer.hostname} ({peer_id[:8]}), refetching")
def _on_local_status_changed(self, event):
@@ -311,6 +311,20 @@ async def _fetch_and_update_peer(self, peer: PeerInfo):
"hostname": peer.hostname,
})
+ def _schedule_status_fetch(self, peer: PeerInfo) -> None:
+ """Schedule a best-effort peer status fetch when the service is running."""
+ if not self._peer_client:
+ return
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ logger.debug(
+ "Mesh: skipping status fetch for %s because no event loop is running",
+ peer.instance_id[:8],
+ )
+ return
+ loop.create_task(self._fetch_and_update_peer(peer))
+
# ------------------------------------------------------------------
# Pairing integration
# ------------------------------------------------------------------
@@ -336,7 +350,7 @@ def mark_peer_trusted(self, instance_id: str):
if cert_fp:
peer.tls_enabled = True
# Kick off an immediate status fetch now that we trust them
- asyncio.ensure_future(self._fetch_and_update_peer(peer))
+ self._schedule_status_fetch(peer)
logger.info(f"Mesh: peer {peer.hostname} ({instance_id[:8]}) now trusted")
# ------------------------------------------------------------------
diff --git a/tests/test_campaign_coordination.py b/tests/test_campaign_coordination.py
index 18ee49e2..49737fc4 100644
--- a/tests/test_campaign_coordination.py
+++ b/tests/test_campaign_coordination.py
@@ -15,6 +15,14 @@
import urllib.request
import urllib.error
+if "pytest" in sys.modules and os.environ.get("GENTLY_RUN_LIVE_CAMPAIGN_TESTS") != "1":
+ import pytest
+
+ pytest.skip(
+ "live campaign coordination script requires a running Gently server",
+ allow_module_level=True,
+ )
+
BASE = os.environ.get("GENTLY_URL", "http://localhost:8080")
FAKE_PEER = "test-peer-001"
FAKE_HOST = "test-machine"
diff --git a/tests/test_dispim_device_safety.py b/tests/test_dispim_device_safety.py
index 40fab85a..9ca67e86 100644
--- a/tests/test_dispim_device_safety.py
+++ b/tests/test_dispim_device_safety.py
@@ -27,7 +27,7 @@ def __init__(self):
self._configs = {} # group -> current_config
self._available_configs = {} # group -> [configs]
self._exposure = 10.0
- self._camera_device = None
+ self._camera_device = ""
self._focus_device = None
self._circular_buffer = []
self._sequence_running = False
@@ -83,6 +83,9 @@ def waitForConfig(self, group, config):
def setCameraDevice(self, name):
self._camera_device = name
+ def getCameraDevice(self):
+ return self._camera_device
+
def setFocusDevice(self, name):
self._focus_device = name
@@ -226,9 +229,9 @@ def make_z_stage(core=None, limits=(50.0, 250.0)):
return DiSPIMZstage("ZStage", core or make_core(), limits=limits)
-def make_xy_stage(core=None, x_limits=(2000.0, 4000.0), y_limits=(-1000.0, 1000.0)):
+def make_xy_stage(core=None):
from gently.hardware.dispim.devices.stage import DiSPIMXYStage
- return DiSPIMXYStage("XYStage", core or make_core(), x_limits=x_limits, y_limits=y_limits)
+ return DiSPIMXYStage("XYStage", core or make_core())
def make_piezo(core=None, limits=(-200.0, 200.0)):
@@ -318,38 +321,40 @@ class TestXYStageBounds:
def test_valid_xy_position(self):
stage = make_xy_stage()
- status = stage.set([3000.0, 0.0])
+ x = (stage.x_limits[0] + stage.x_limits[1]) / 2.0
+ y = (stage.y_limits[0] + stage.y_limits[1]) / 2.0
+ status = stage.set([x, y])
status.wait(timeout=2)
- assert stage.core._xy_position == (3000.0, 0.0)
+ assert stage.core._xy_position == (x, y)
def test_x_below_lower_limit(self):
stage = make_xy_stage()
- status = stage.set([1999.0, 0.0])
- with pytest.raises(ValueError, match="outside limits"):
+ status = stage.set([stage.x_limits[0] - 1.0, 0.0])
+ with pytest.raises(ValueError, match="outside hardware limits"):
status.wait(timeout=2)
def test_x_above_upper_limit(self):
stage = make_xy_stage()
- status = stage.set([4001.0, 0.0])
- with pytest.raises(ValueError, match="outside limits"):
+ status = stage.set([stage.x_limits[1] + 1.0, 0.0])
+ with pytest.raises(ValueError, match="outside hardware limits"):
status.wait(timeout=2)
def test_y_below_lower_limit(self):
stage = make_xy_stage()
- status = stage.set([3000.0, -1001.0])
- with pytest.raises(ValueError, match="outside limits"):
+ status = stage.set([0.0, stage.y_limits[0] - 1.0])
+ with pytest.raises(ValueError, match="outside hardware limits"):
status.wait(timeout=2)
def test_y_above_upper_limit(self):
stage = make_xy_stage()
- status = stage.set([3000.0, 1001.0])
- with pytest.raises(ValueError, match="outside limits"):
+ status = stage.set([0.0, stage.y_limits[1] + 1.0])
+ with pytest.raises(ValueError, match="outside hardware limits"):
status.wait(timeout=2)
def test_core_not_called_on_invalid_x(self):
core = make_core()
stage = make_xy_stage(core=core)
- stage.set([0.0, 0.0]) # x=0 is below x_limits[0]=2000
+ stage.set([stage.x_limits[0] - 1.0, 0.0])
time.sleep(0.1)
assert not any(c[0] == 'setXYPosition' for c in core.call_log)
From baec0b00d2032070b3a7d5ea91b3b2d6ff09bb11 Mon Sep 17 00:00:00 2001
From: ceej640 <42260127+Ceej640@users.noreply.github.com>
Date: Sat, 30 May 2026 20:47:49 -0400
Subject: [PATCH 2/4] Harden web control auth and upload inputs
---
gently/ui/web/auth.py | 6 +-
gently/ui/web/routes/agent_ws.py | 24 ++-----
gently/ui/web/routes/images.py | 22 ++++--
gently/ui/web/routes/volumes.py | 33 ++++++---
gently/ui/web/routes/websocket.py | 16 ++---
gently/ui/web/upload_validation.py | 59 ++++++++++++++++
launch_gently.py | 22 +++---
tests/test_web_auth_hardening.py | 110 +++++++++++++++++++++++++++++
8 files changed, 232 insertions(+), 60 deletions(-)
create mode 100644 gently/ui/web/upload_validation.py
create mode 100644 tests/test_web_auth_hardening.py
diff --git a/gently/ui/web/auth.py b/gently/ui/web/auth.py
index 2f2d9927..34486e72 100644
--- a/gently/ui/web/auth.py
+++ b/gently/ui/web/auth.py
@@ -26,7 +26,7 @@ async def foo(_=Depends(require_control)):
import os
from enum import Enum
-from fastapi import HTTPException, Request
+from fastapi import HTTPException, Request, WebSocket
logger = logging.getLogger(__name__)
@@ -47,7 +47,7 @@ class Role(str, Enum):
CONTROL = "control"
-def current_username(request: Request) -> str | None:
+def current_username(request: Request | WebSocket) -> str | None:
"""Return the authenticated username from the session cookie, or None.
None when no account store is configured or the cookie is missing/invalid.
@@ -70,7 +70,7 @@ def _configured_token() -> str | None:
return tok or None
-def resolve_role(request: Request) -> Role:
+def resolve_role(request: Request | WebSocket) -> Role:
"""Determine the effective role for a request.
Account mode (preferred): if user accounts are configured, identity comes
diff --git a/gently/ui/web/routes/agent_ws.py b/gently/ui/web/routes/agent_ws.py
index 8f3a3262..37e1b643 100644
--- a/gently/ui/web/routes/agent_ws.py
+++ b/gently/ui/web/routes/agent_ws.py
@@ -292,23 +292,13 @@ async def agent_websocket(websocket: WebSocket):
bridge.register_display_broadcaster(_broadcast)
# ── Authenticate the connection (account mode) ────────────
- # When user accounts are configured, identity comes from the signed
- # session cookie (set at login). Viewers may watch but not drive;
- # operators/admins may take the control lock. With no accounts
- # configured we fall back to the legacy "anyone connected can drive".
- from gently.ui.web.accounts import get_account_store, CONTROL_ROLES
- from gently.ui.web.auth import SESSION_COOKIE
- _acct = get_account_store()
- username = None
- can_control = True # legacy default when no accounts are configured
- if _acct is not None and _acct.has_users():
- # Viewing is open: anonymous clients may connect and *watch* the
- # conversation. Only authenticated operators/admins can hold or
- # take the control lock (enforced on the drive actions below).
- _token = websocket.cookies.get(SESSION_COOKIE)
- username = _acct.verify_session(_token) if _token else None
- role = _acct.get_role(username) if username else None
- can_control = role in CONTROL_ROLES
+ # Viewing is open: anonymous clients may connect and watch the
+ # conversation. Driving uses the same role resolver as REST: accounts
+ # rely on the signed session cookie; legacy no-account mode grants
+ # localhost control and requires X-Gently-Token for remote clients.
+ from gently.ui.web.auth import Role, current_username, resolve_role
+ username = current_username(websocket)
+ can_control = resolve_role(websocket) is Role.CONTROL
# Assign a stable id for control arbitration. The label shown to other
# clients is the username when authenticated, else a generic window id.
diff --git a/gently/ui/web/routes/images.py b/gently/ui/web/routes/images.py
index 5d505b6a..dafa96d7 100644
--- a/gently/ui/web/routes/images.py
+++ b/gently/ui/web/routes/images.py
@@ -5,13 +5,17 @@
from typing import Optional
import numpy as np
-from fastapi import APIRouter, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response, FileResponse
+from gently.ui.web.auth import require_control
from ..volume_helpers import parse_volume_uid
+from ..upload_validation import decode_array_payload
logger = logging.getLogger(__name__)
+MAX_IMAGE_UPLOAD_BYTES = 64 * 1024 * 1024
+
def create_router(server) -> APIRouter:
router = APIRouter()
@@ -124,7 +128,7 @@ async def get_image_png(uid: str):
raise HTTPException(status_code=404, detail=f"Image {uid} not found")
- @router.post("/api/images")
+ @router.post("/api/images", dependencies=[Depends(require_control)])
async def push_image_http(request: Request):
"""Push a 2D image via HTTP (for CV subagent visualizations)"""
try:
@@ -141,17 +145,21 @@ async def push_image_http(request: Request):
if not all([image_b64, uid, shape]):
raise HTTPException(status_code=400, detail="Missing required fields")
- # Decode array
- array = np.frombuffer(
- base64.b64decode(image_b64),
- dtype=np.dtype(dtype)
- ).reshape(shape)
+ array = decode_array_payload(
+ image_b64,
+ shape,
+ dtype,
+ max_nbytes=MAX_IMAGE_UPLOAD_BYTES,
+ label="image",
+ )
# Push using the existing method
await server.push_image(array, uid, data_type, metadata)
return {"status": "ok", "uid": uid, "data_type": data_type}
+ except HTTPException:
+ raise
except Exception as e:
logger.error(f"Failed to push image via HTTP: {e}")
raise HTTPException(status_code=500, detail=str(e))
diff --git a/gently/ui/web/routes/volumes.py b/gently/ui/web/routes/volumes.py
index e48475c3..cb39b109 100644
--- a/gently/ui/web/routes/volumes.py
+++ b/gently/ui/web/routes/volumes.py
@@ -6,13 +6,17 @@
from typing import Optional
import numpy as np
-from fastapi import APIRouter, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
+from gently.ui.web.auth import require_control
from ..volume_helpers import load_volume_from_disk, image_to_base64_png
+from ..upload_validation import decode_array_payload
logger = logging.getLogger(__name__)
+MAX_VOLUME_UPLOAD_BYTES = 512 * 1024 * 1024
+
try:
from PIL import Image
PIL_AVAILABLE = True
@@ -220,7 +224,7 @@ async def get_volume_data_for_3d_viewer(uid: str):
raise HTTPException(status_code=404, detail=f"Volume {uid} not found")
- @router.post("/api/volumes3d")
+ @router.post("/api/volumes3d", dependencies=[Depends(require_control)])
async def push_volume_3d_http(request: Request):
"""Push a 3D volume with segmentation via HTTP (for CV subagent)"""
try:
@@ -238,22 +242,29 @@ async def push_volume_3d_http(request: Request):
if not all([volume_b64, masks_b64, uid, shape]):
raise HTTPException(status_code=400, detail="Missing required fields")
- # Decode arrays
- volume = np.frombuffer(
- base64.b64decode(volume_b64),
- dtype=np.dtype(dtype_vol)
- ).reshape(shape)
+ volume = decode_array_payload(
+ volume_b64,
+ shape,
+ dtype_vol,
+ max_nbytes=MAX_VOLUME_UPLOAD_BYTES,
+ label="volume",
+ )
- masks = np.frombuffer(
- base64.b64decode(masks_b64),
- dtype=np.dtype(dtype_mask)
- ).reshape(shape)
+ masks = decode_array_payload(
+ masks_b64,
+ shape,
+ dtype_mask,
+ max_nbytes=MAX_VOLUME_UPLOAD_BYTES,
+ label="masks",
+ )
# Push using the existing method
await server.push_volume_3d(volume, masks, uid, metadata)
return {"status": "ok", "uid": uid, "shape": shape}
+ except HTTPException:
+ raise
except Exception as e:
logger.error(f"Failed to push 3D volume via HTTP: {e}")
raise HTTPException(status_code=500, detail=str(e))
diff --git a/gently/ui/web/routes/websocket.py b/gently/ui/web/routes/websocket.py
index 3b35f3e1..35b7f9de 100644
--- a/gently/ui/web/routes/websocket.py
+++ b/gently/ui/web/routes/websocket.py
@@ -22,18 +22,12 @@
def _ws_can_control(websocket: WebSocket) -> bool:
"""Whether this /ws client may perform control actions (marking).
- Account mode: operators/admins (by session cookie) only. Legacy mode
- (no accounts configured): open, preserving prior behavior.
+ Mirrors the REST role resolver: accounts use the session cookie; legacy
+ no-account mode grants localhost control and requires X-Gently-Token for
+ remote control.
"""
- from gently.ui.web.accounts import get_account_store, CONTROL_ROLES
- from gently.ui.web.auth import SESSION_COOKIE
- store = get_account_store()
- if store is None or not store.has_users():
- return True
- token = websocket.cookies.get(SESSION_COOKIE)
- user = store.verify_session(token) if token else None
- role = store.get_role(user) if user else None
- return role in CONTROL_ROLES
+ from gently.ui.web.auth import Role, resolve_role
+ return resolve_role(websocket) is Role.CONTROL
def create_router(server) -> APIRouter:
diff --git a/gently/ui/web/upload_validation.py b/gently/ui/web/upload_validation.py
new file mode 100644
index 00000000..57f37a52
--- /dev/null
+++ b/gently/ui/web/upload_validation.py
@@ -0,0 +1,59 @@
+"""Validation helpers for HTTP array upload routes."""
+
+from __future__ import annotations
+
+import base64
+import binascii
+import math
+from typing import Iterable
+
+import numpy as np
+from fastapi import HTTPException
+
+
+def decode_array_payload(
+ encoded: str,
+ shape: Iterable[int],
+ dtype_name: str,
+ *,
+ max_nbytes: int,
+ label: str,
+) -> np.ndarray:
+ """Decode a base64 array after validating shape, dtype, and byte count."""
+ if not isinstance(encoded, str) or not encoded:
+ raise HTTPException(status_code=400, detail=f"{label} payload must be base64 text")
+ if isinstance(shape, (str, bytes)) or not isinstance(shape, Iterable):
+ raise HTTPException(status_code=400, detail=f"{label} shape must be a list of dimensions")
+
+ try:
+ shape_tuple = tuple(int(dim) for dim in shape)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail=f"{label} shape must contain integers")
+ if not shape_tuple or len(shape_tuple) > 4 or any(dim <= 0 for dim in shape_tuple):
+ raise HTTPException(status_code=400, detail=f"{label} shape must have 1-4 positive dimensions")
+
+ try:
+ dtype = np.dtype(dtype_name)
+ except TypeError:
+ raise HTTPException(status_code=400, detail=f"{label} dtype is not supported")
+ if dtype.hasobject:
+ raise HTTPException(status_code=400, detail=f"{label} dtype may not contain Python objects")
+
+ expected_nbytes = math.prod(shape_tuple) * dtype.itemsize
+ if expected_nbytes > max_nbytes:
+ raise HTTPException(
+ status_code=413,
+ detail=f"{label} payload is too large ({expected_nbytes} bytes > {max_nbytes} bytes)",
+ )
+
+ try:
+ raw = base64.b64decode(encoded, validate=True)
+ except (binascii.Error, ValueError):
+ raise HTTPException(status_code=400, detail=f"{label} payload is not valid base64")
+ if len(raw) != expected_nbytes:
+ raise HTTPException(
+ status_code=400,
+ detail=f"{label} byte length {len(raw)} does not match shape/dtype {expected_nbytes}",
+ )
+
+ return np.frombuffer(raw, dtype=dtype).reshape(shape_tuple)
diff --git a/launch_gently.py b/launch_gently.py
index a2e590fa..93c07bd0 100644
--- a/launch_gently.py
+++ b/launch_gently.py
@@ -235,6 +235,12 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions:
store = FileStore(storage_dir)
# ── Accounts / auth ───────────────────────────────────────────
+ # Handle --sessions (just list and exit)
+ if show_sessions:
+ list_sessions(store)
+ store.close()
+ return
+
# Self-managed user accounts gate microscope control on the LAN. On first
# run we bootstrap an admin and print its one-time password in the banner.
# Set GENTLY_NO_AUTH=1 to disable accounts (legacy localhost-control mode).
@@ -248,12 +254,6 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions:
except Exception as e:
logger.error("Account store init failed (continuing without auth): %s", e)
- # Handle --sessions (just list and exit)
- if show_sessions:
- list_sessions(store)
- store.close()
- return
-
# Web-only: the TUI is retired. The browser is the control surface and
# the launcher just starts the server — no Node/dist requirement.
@@ -562,11 +562,6 @@ def _sig(*_a):
def cli_main():
"""Sync entry point for ``gently`` console script (pyproject.toml)."""
- if not os.getenv("ANTHROPIC_API_KEY"):
- print("Error: ANTHROPIC_API_KEY not set")
- print("Set with: set ANTHROPIC_API_KEY=your-key")
- exit(1)
-
parser = argparse.ArgumentParser(description="Launch Microscopy Agent")
parser.add_argument("--offline", action="store_true", help="Run without server connections")
parser.add_argument("--sessions", action="store_true", help="List available sessions and exit")
@@ -577,6 +572,11 @@ def cli_main():
parser.add_argument("--no-browser", action="store_true", help="Do not auto-open the web UI in a browser")
args = parser.parse_args()
+ if not args.sessions and not os.getenv("ANTHROPIC_API_KEY"):
+ print("Error: ANTHROPIC_API_KEY not set")
+ print("Set with: set ANTHROPIC_API_KEY=your-key")
+ exit(1)
+
log_level = "WARNING"
if args.verbose:
log_level = "INFO"
diff --git a/tests/test_web_auth_hardening.py b/tests/test_web_auth_hardening.py
new file mode 100644
index 00000000..938f65e4
--- /dev/null
+++ b/tests/test_web_auth_hardening.py
@@ -0,0 +1,110 @@
+import base64
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+from fastapi import FastAPI, HTTPException
+from fastapi.testclient import TestClient
+
+from gently.ui.web.accounts import AccountStore, set_account_store
+from gently.ui.web.auth import Role, SESSION_COOKIE, resolve_role
+from gently.ui.web.routes.images import create_router as create_images_router
+from gently.ui.web.routes.volumes import create_router as create_volumes_router
+from gently.ui.web.routes.websocket import _ws_can_control
+from gently.ui.web.upload_validation import decode_array_payload
+
+
+class _Client:
+ def __init__(self, host):
+ self.host = host
+
+
+class _RequestLike:
+ def __init__(self, host="192.0.2.10", headers=None, cookies=None):
+ self.client = _Client(host)
+ self.headers = headers or {}
+ self.cookies = cookies or {}
+
+
+@pytest.fixture(autouse=True)
+def _reset_auth(monkeypatch):
+ set_account_store(None)
+ monkeypatch.delenv("GENTLY_CONTROL_TOKEN", raising=False)
+ yield
+ set_account_store(None)
+
+
+def test_legacy_websocket_control_requires_token_for_remote(monkeypatch):
+ remote = _RequestLike()
+ assert _ws_can_control(remote) is False
+
+ monkeypatch.setenv("GENTLY_CONTROL_TOKEN", "secret")
+ with_token = _RequestLike(headers={"X-Gently-Token": "secret"})
+ assert _ws_can_control(with_token) is True
+
+
+def test_legacy_loopback_keeps_control_without_token():
+ assert resolve_role(_RequestLike(host="127.0.0.1")) is Role.CONTROL
+
+
+def test_account_roles_drive_websocket_control(tmp_path):
+ store = AccountStore(tmp_path / "auth")
+ store.create_user("viewer", "pw", role="viewer")
+ store.create_user("operator", "pw", role="operator")
+ set_account_store(store)
+
+ viewer = _RequestLike(cookies={SESSION_COOKIE: store.issue_session("viewer")})
+ operator = _RequestLike(cookies={SESSION_COOKIE: store.issue_session("operator")})
+
+ assert _ws_can_control(viewer) is False
+ assert _ws_can_control(operator) is True
+
+
+def test_image_push_requires_control(monkeypatch):
+ app = FastAPI()
+ pushed = {}
+
+ async def push_image(array, uid, data_type, metadata):
+ pushed["shape"] = array.shape
+ pushed["uid"] = uid
+
+ server = SimpleNamespace(push_image=push_image)
+ app.include_router(create_images_router(server))
+ client = TestClient(app)
+
+ arr = np.array([[1, 2], [3, 4]], dtype=np.uint8)
+ payload = {
+ "uid": "img-1",
+ "shape": list(arr.shape),
+ "dtype": str(arr.dtype),
+ "image_b64": base64.b64encode(arr.tobytes()).decode("ascii"),
+ }
+
+ assert client.post("/api/images", json=payload).status_code == 403
+
+ monkeypatch.setenv("GENTLY_CONTROL_TOKEN", "secret")
+ resp = client.post("/api/images", json=payload, headers={"X-Gently-Token": "secret"})
+ assert resp.status_code == 200
+ assert pushed == {"shape": (2, 2), "uid": "img-1"}
+
+
+def test_volume_push_requires_control():
+ app = FastAPI()
+ app.include_router(create_volumes_router(SimpleNamespace()))
+ client = TestClient(app)
+
+ assert client.post("/api/volumes3d", json={}).status_code == 403
+
+
+def test_decode_array_payload_rejects_oversize_shape():
+ raw = base64.b64encode(b"\x00").decode("ascii")
+ with pytest.raises(HTTPException) as exc:
+ decode_array_payload(raw, [8], "uint8", max_nbytes=4, label="image")
+ assert exc.value.status_code == 413
+
+
+def test_decode_array_payload_rejects_shape_mismatch():
+ raw = base64.b64encode(b"\x00").decode("ascii")
+ with pytest.raises(HTTPException) as exc:
+ decode_array_payload(raw, [2], "uint8", max_nbytes=4, label="image")
+ assert exc.value.status_code == 400
From a81d15f9631741a0e2dcdd64c7d067e6aae13e2b Mon Sep 17 00:00:00 2001
From: Johnson
Date: Mon, 1 Jun 2026 00:53:16 -0400
Subject: [PATCH 3/4] Add web user management controls
---
gently/ui/web/accounts.py | 37 ++++++++++++++
gently/ui/web/routes/auth_routes.py | 64 +++++++++++++++++++++--
launch_gently.py | 78 ++++++++++++++++++++++++++++-
tests/test_web_auth_hardening.py | 46 +++++++++++++++++
4 files changed, 218 insertions(+), 7 deletions(-)
diff --git a/gently/ui/web/accounts.py b/gently/ui/web/accounts.py
index 2a534c44..cb216ac3 100644
--- a/gently/ui/web/accounts.py
+++ b/gently/ui/web/accounts.py
@@ -118,6 +118,43 @@ def create_user(self, username: str, password: str, role: str = "viewer") -> Non
}
self._save_users()
+ def set_role(self, username: str, role: str) -> None:
+ username = (username or "").strip()
+ if username not in self._users:
+ raise ValueError(f"user not found: {username}")
+ if role not in ROLES:
+ raise ValueError(f"role must be one of {ROLES}")
+ if self._users[username].get("role") == "admin" and role != "admin":
+ if self._admin_count() <= 1:
+ raise ValueError("cannot demote the last admin user")
+ self._users[username]["role"] = role
+ self._save_users()
+
+ def reset_password(self, username: str, password: str) -> None:
+ username = (username or "").strip()
+ if username not in self._users:
+ raise ValueError(f"user not found: {username}")
+ salt = secrets.token_bytes(16)
+ self._users[username].update({
+ "salt": salt.hex(),
+ "hash": self._hash(password, salt, _PBKDF2_ITERATIONS).hex(),
+ "iterations": _PBKDF2_ITERATIONS,
+ "password_updated_at": datetime.now().isoformat(timespec="seconds"),
+ })
+ self._save_users()
+
+ def delete_user(self, username: str) -> None:
+ username = (username or "").strip()
+ if username not in self._users:
+ raise ValueError(f"user not found: {username}")
+ if self._users[username].get("role") == "admin" and self._admin_count() <= 1:
+ raise ValueError("cannot delete the last admin user")
+ del self._users[username]
+ self._save_users()
+
+ def _admin_count(self) -> int:
+ return sum(1 for rec in self._users.values() if rec.get("role") == "admin")
+
def verify_password(self, username: str, password: str) -> Optional[str]:
"""Return the user's role if the password matches, else None."""
rec = self._users.get((username or "").strip())
diff --git a/gently/ui/web/routes/auth_routes.py b/gently/ui/web/routes/auth_routes.py
index e7db5500..fbe464d9 100644
--- a/gently/ui/web/routes/auth_routes.py
+++ b/gently/ui/web/routes/auth_routes.py
@@ -24,6 +24,15 @@ def _secure(request: Request) -> bool:
# plain-HTTP LAN deployments.
return request.url.scheme == "https"
+ def _require_admin(request: Request):
+ store = get_account_store()
+ if store is None:
+ return None, JSONResponse({"error": "accounts not configured"}, status_code=400)
+ requester = current_username(request)
+ if not requester or store.get_role(requester) != "admin":
+ return None, JSONResponse({"error": "admin role required"}, status_code=403)
+ return store, None
+
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
store = get_account_store()
@@ -83,12 +92,10 @@ async def me(request: Request):
@router.post("/api/auth/users")
async def create_user(request: Request):
"""Admin-only: provision a new account."""
- store = get_account_store()
- if store is None:
- return JSONResponse({"error": "accounts not configured"}, status_code=400)
+ store, error = _require_admin(request)
+ if error:
+ return error
requester = current_username(request)
- if not requester or store.get_role(requester) != "admin":
- return JSONResponse({"error": "admin role required"}, status_code=403)
try:
body = await request.json()
except Exception:
@@ -107,4 +114,51 @@ async def create_user(request: Request):
logger.info("admin %s created user %s (%s)", requester, new_user, role)
return JSONResponse({"ok": True, "username": new_user, "role": role})
+ @router.get("/api/auth/users")
+ async def list_users(request: Request):
+ """Admin-only: list configured accounts."""
+ store, error = _require_admin(request)
+ if error:
+ return error
+ return JSONResponse({"ok": True, "users": store.list_users()})
+
+ @router.patch("/api/auth/users/{username}")
+ async def update_user(username: str, request: Request):
+ """Admin-only: change a role and/or reset a password."""
+ store, error = _require_admin(request)
+ if error:
+ return error
+ requester = current_username(request)
+ try:
+ body = await request.json()
+ except Exception:
+ body = {}
+
+ changed = []
+ try:
+ if "role" in body and body["role"]:
+ store.set_role(username, body["role"])
+ changed.append("role")
+ if "password" in body and body["password"]:
+ store.reset_password(username, body["password"])
+ changed.append("password")
+ except ValueError as e:
+ return JSONResponse({"error": str(e)}, status_code=400)
+ logger.info("admin %s updated user %s (%s)", requester, username, ",".join(changed))
+ return JSONResponse({"ok": True, "username": username, "changed": changed})
+
+ @router.delete("/api/auth/users/{username}")
+ async def delete_user(username: str, request: Request):
+ """Admin-only: remove an account."""
+ store, error = _require_admin(request)
+ if error:
+ return error
+ requester = current_username(request)
+ try:
+ store.delete_user(username)
+ except ValueError as e:
+ return JSONResponse({"error": str(e)}, status_code=400)
+ logger.info("admin %s deleted user %s", requester, username)
+ return JSONResponse({"ok": True, "username": username})
+
return router
diff --git a/launch_gently.py b/launch_gently.py
index 93c07bd0..df500443 100644
--- a/launch_gently.py
+++ b/launch_gently.py
@@ -21,6 +21,7 @@
"""
import asyncio
+import getpass
import json
import logging
import os
@@ -35,7 +36,6 @@
from gently.log_config import configure_logging
from gently.core.log_bridge import configure_log_bridge
-from gently.app.agent import MicroscopyAgent
from gently.organisms import load_organism
from gently.hardware import load_hardware, get_hardware
from gently.settings import settings
@@ -95,6 +95,62 @@ def list_sessions(store: FileStore):
print("Use: python launch_gently.py --resume ")
+def _manage_users(args) -> int:
+ """Run account-management commands without starting the web server."""
+ from gently.ui.web.accounts import AccountStore, ROLES
+
+ store = AccountStore(settings.storage.base_path / "auth")
+
+ if args.users:
+ users = store.list_users()
+ if not users:
+ print("No web UI users configured.")
+ return 0
+ print("Web UI users")
+ print("-" * 30)
+ for user in users:
+ print(f" {user['username']}\t{user['role']}")
+ return 0
+
+ if args.add_user:
+ password = args.password or getpass.getpass(f"Password for {args.add_user}: ")
+ store.create_user(args.add_user, password, role=args.role)
+ print(f"Created user {args.add_user} ({args.role}).")
+ return 0
+
+ if args.reset_password:
+ password = args.password or getpass.getpass(f"New password for {args.reset_password}: ")
+ store.reset_password(args.reset_password, password)
+ print(f"Reset password for {args.reset_password}.")
+ return 0
+
+ if args.set_role:
+ username, role = args.set_role
+ if role not in ROLES:
+ print(f"Error: role must be one of {', '.join(ROLES)}")
+ return 2
+ store.set_role(username, role)
+ print(f"Set {username} role to {role}.")
+ return 0
+
+ if args.delete_user:
+ store.delete_user(args.delete_user)
+ print(f"Deleted user {args.delete_user}.")
+ return 0
+
+ return 1
+
+
+def _has_user_management_command(args) -> bool:
+ return any([
+ args.users,
+ args.add_user,
+ args.reset_password,
+ args.set_role,
+ args.delete_user,
+ ])
+
+
def _print_banner(viz_url, device_connected, offline, storage_dir, log_file, resumed):
"""Print a human-readable launch banner to the terminal.
@@ -241,6 +297,8 @@ async def main(offline: bool = False, resume_session: str = None, show_sessions:
store.close()
return
+ from gently.app.agent import MicroscopyAgent
+
# Self-managed user accounts gate microscope control on the LAN. On first
# run we bootstrap an admin and print its one-time password in the banner.
# Set GENTLY_NO_AUTH=1 to disable accounts (legacy localhost-control mode).
@@ -509,7 +567,7 @@ def _status_provider():
print(" First-run admin account created — sign in at the URL above:")
print(f" username: {_u}")
print(f" password: {_p}")
- print(" (Save this now. Add users via the admin API; GENTLY_NO_AUTH=1 disables auth.)\n")
+ print(" (Save this now. Add users with --add-user or the admin API; GENTLY_NO_AUTH=1 disables auth.)\n")
if viz_url and not no_browser:
_open_browser(viz_url)
@@ -570,8 +628,24 @@ def cli_main():
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose (INFO) logging")
parser.add_argument("--debug", action="store_true", help="Enable debug logging (most verbose)")
parser.add_argument("--no-browser", action="store_true", help="Do not auto-open the web UI in a browser")
+ parser.add_argument("--users", action="store_true", help="List web UI users and exit")
+ parser.add_argument("--add-user", metavar="USERNAME", help="Create a web UI user and exit")
+ parser.add_argument("--reset-password", metavar="USERNAME", help="Reset a web UI user's password and exit")
+ parser.add_argument("--set-role", nargs=2, metavar=("USERNAME", "ROLE"),
+ help="Set a web UI user's role: viewer, operator, or admin")
+ parser.add_argument("--delete-user", metavar="USERNAME", help="Delete a web UI user and exit")
+ parser.add_argument("--role", default="viewer", choices=("viewer", "operator", "admin"),
+ help="Role for --add-user (default: viewer)")
+ parser.add_argument("--password", help="Password for --add-user or --reset-password")
args = parser.parse_args()
+ if _has_user_management_command(args):
+ try:
+ sys.exit(_manage_users(args))
+ except ValueError as e:
+ print(f"Error: {e}")
+ sys.exit(2)
+
if not args.sessions and not os.getenv("ANTHROPIC_API_KEY"):
print("Error: ANTHROPIC_API_KEY not set")
print("Set with: set ANTHROPIC_API_KEY=your-key")
diff --git a/tests/test_web_auth_hardening.py b/tests/test_web_auth_hardening.py
index 938f65e4..cef82bfa 100644
--- a/tests/test_web_auth_hardening.py
+++ b/tests/test_web_auth_hardening.py
@@ -9,6 +9,7 @@
from gently.ui.web.accounts import AccountStore, set_account_store
from gently.ui.web.auth import Role, SESSION_COOKIE, resolve_role
from gently.ui.web.routes.images import create_router as create_images_router
+from gently.ui.web.routes.auth_routes import create_router as create_auth_router
from gently.ui.web.routes.volumes import create_router as create_volumes_router
from gently.ui.web.routes.websocket import _ws_can_control
from gently.ui.web.upload_validation import decode_array_payload
@@ -60,6 +61,51 @@ def test_account_roles_drive_websocket_control(tmp_path):
assert _ws_can_control(operator) is True
+def test_account_store_manages_roles_passwords_and_deletes(tmp_path):
+ store = AccountStore(tmp_path / "auth")
+ store.create_user("admin", "pw", role="admin")
+ store.create_user("viewer", "pw", role="viewer")
+
+ store.set_role("viewer", "operator")
+ store.reset_password("viewer", "new-pw")
+ assert store.get_role("viewer") == "operator"
+ assert store.verify_password("viewer", "new-pw") == "operator"
+
+ store.delete_user("viewer")
+ assert store.get_role("viewer") is None
+
+ with pytest.raises(ValueError, match="last admin"):
+ store.delete_user("admin")
+
+
+def test_admin_api_lists_updates_and_deletes_users(tmp_path):
+ store = AccountStore(tmp_path / "auth")
+ store.create_user("admin", "pw", role="admin")
+ store.create_user("viewer", "pw", role="viewer")
+ set_account_store(store)
+
+ app = FastAPI()
+ app.include_router(create_auth_router(SimpleNamespace(templates=None)))
+ client = TestClient(app)
+ client.cookies.set(SESSION_COOKIE, store.issue_session("admin"))
+
+ resp = client.get("/api/auth/users")
+ assert resp.status_code == 200
+ assert {u["username"] for u in resp.json()["users"]} == {"admin", "viewer"}
+
+ resp = client.patch(
+ "/api/auth/users/viewer",
+ json={"role": "operator", "password": "new-pw"},
+ )
+ assert resp.status_code == 200
+ assert store.get_role("viewer") == "operator"
+ assert store.verify_password("viewer", "new-pw") == "operator"
+
+ resp = client.delete("/api/auth/users/viewer")
+ assert resp.status_code == 200
+ assert store.get_role("viewer") is None
+
+
def test_image_push_requires_control(monkeypatch):
app = FastAPI()
pushed = {}
From 19577abcc7d02d2e17f34eef8bd3332a2489102c Mon Sep 17 00:00:00 2001
From: Johnson
Date: Mon, 1 Jun 2026 01:49:24 -0400
Subject: [PATCH 4/4] Add admin user management page
---
gently/ui/web/accounts.py | 2 +
gently/ui/web/routes/auth_routes.py | 18 +-
gently/ui/web/static/css/main.css | 199 +++++++++++++++++++++++
gently/ui/web/static/js/admin-users.js | 181 +++++++++++++++++++++
gently/ui/web/templates/_header.html | 18 ++
gently/ui/web/templates/admin_users.html | 70 ++++++++
tests/test_web_auth_hardening.py | 44 +++++
7 files changed, 531 insertions(+), 1 deletion(-)
create mode 100644 gently/ui/web/static/js/admin-users.js
create mode 100644 gently/ui/web/templates/admin_users.html
diff --git a/gently/ui/web/accounts.py b/gently/ui/web/accounts.py
index cb216ac3..07db175c 100644
--- a/gently/ui/web/accounts.py
+++ b/gently/ui/web/accounts.py
@@ -106,6 +106,8 @@ def create_user(self, username: str, password: str, role: str = "viewer") -> Non
username = (username or "").strip()
if not username:
raise ValueError("username required")
+ if username in self._users:
+ raise ValueError(f"user already exists: {username}")
if role not in ROLES:
raise ValueError(f"role must be one of {ROLES}")
salt = secrets.token_bytes(16)
diff --git a/gently/ui/web/routes/auth_routes.py b/gently/ui/web/routes/auth_routes.py
index fbe464d9..3706844b 100644
--- a/gently/ui/web/routes/auth_routes.py
+++ b/gently/ui/web/routes/auth_routes.py
@@ -40,7 +40,23 @@ async def login_page(request: Request):
return RedirectResponse("/", status_code=302)
if current_username(request):
return RedirectResponse("/", status_code=302)
- return server.templates.TemplateResponse("login.html", {"request": request})
+ return server.templates.TemplateResponse(request, "login.html")
+
+ @router.get("/admin/users", response_class=HTMLResponse)
+ async def admin_users_page(request: Request):
+ store = get_account_store()
+ if store is None or not store.has_users():
+ return RedirectResponse("/", status_code=302)
+ requester = current_username(request)
+ if not requester:
+ return RedirectResponse("/login", status_code=302)
+ if store.get_role(requester) != "admin":
+ return HTMLResponse("Forbidden", status_code=403)
+ return server.templates.TemplateResponse(
+ request,
+ "admin_users.html",
+ {"username": requester},
+ )
@router.post("/api/auth/login")
async def login(request: Request):
diff --git a/gently/ui/web/static/css/main.css b/gently/ui/web/static/css/main.css
index fdafcc1d..df41bb17 100644
--- a/gently/ui/web/static/css/main.css
+++ b/gently/ui/web/static/css/main.css
@@ -1525,6 +1525,205 @@ a.tab-link.active {
font-family: monospace;
}
+/* Admin users */
+.admin-users-page {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding: 1.5rem;
+ background: var(--bg-dark);
+}
+
+.admin-users-shell {
+ max-width: 1120px;
+ margin: 0 auto;
+}
+
+.admin-users-head {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.admin-users-head h1 {
+ margin: 0;
+ color: var(--accent);
+ font-size: 1.25rem;
+}
+
+.admin-users-subtitle {
+ margin-top: 0.25rem;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+}
+
+.admin-users-status {
+ min-height: 1.2rem;
+ color: var(--text-muted);
+ font-size: 0.8rem;
+ text-align: right;
+}
+
+.admin-users-status[data-tone="ok"] {
+ color: var(--accent-green);
+}
+
+.admin-users-status[data-tone="error"] {
+ color: var(--accent-orange);
+}
+
+.admin-user-create {
+ display: grid;
+ grid-template-columns: minmax(160px, 1fr) minmax(180px, 1fr) minmax(130px, 160px) auto;
+ gap: 0.75rem;
+ align-items: end;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-card);
+}
+
+.admin-user-create label,
+.admin-password-reset {
+ display: flex;
+ gap: 0.45rem;
+}
+
+.admin-user-create label {
+ flex-direction: column;
+}
+
+.admin-user-create span {
+ color: var(--text-muted);
+ font-size: 0.76rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.02em;
+}
+
+.admin-primary-btn,
+.admin-secondary-btn,
+.admin-danger-btn {
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ padding: 0.45rem 0.75rem;
+ color: var(--text);
+ background: var(--bg-hover);
+ cursor: pointer;
+ font-size: 0.82rem;
+ transition: border-color 0.15s, background 0.15s, color 0.15s;
+}
+
+.admin-primary-btn {
+ color: #fff;
+ border-color: var(--accent);
+ background: var(--accent);
+}
+
+.admin-secondary-btn:hover,
+.admin-danger-btn:hover:not(:disabled) {
+ border-color: var(--accent);
+}
+
+.admin-danger-btn {
+ color: var(--accent-orange);
+}
+
+.admin-danger-btn:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+.admin-users-table-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-card);
+}
+
+.admin-users-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.admin-users-table th,
+.admin-users-table td {
+ padding: 0.75rem;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+ vertical-align: middle;
+}
+
+.admin-users-table th {
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background: var(--bg-dark);
+}
+
+.admin-users-table tr:last-child td {
+ border-bottom: none;
+}
+
+.admin-users-name {
+ font-weight: 600;
+}
+
+.admin-current-user {
+ display: inline-flex;
+ margin-left: 0.5rem;
+ padding: 0.12rem 0.35rem;
+ border-radius: 4px;
+ color: var(--accent-green);
+ background: rgba(74, 222, 128, 0.12);
+ font-size: 0.68rem;
+ font-weight: 600;
+}
+
+.admin-password-reset {
+ align-items: center;
+}
+
+.admin-password-reset .settings-input {
+ min-width: 180px;
+}
+
+.admin-role-select {
+ min-width: 130px;
+}
+
+.admin-users-actions {
+ width: 1%;
+ white-space: nowrap;
+}
+
+.admin-users-empty {
+ color: var(--text-muted);
+ text-align: center;
+}
+
+@media (max-width: 800px) {
+ .admin-users-page {
+ padding: 1rem;
+ }
+ .admin-users-head {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .admin-user-create {
+ grid-template-columns: 1fr;
+ }
+ .admin-password-reset {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
+
/* ===================================================================
* Calibration → Profile v2 — metrics-first 2x2 layout
* =================================================================== */
diff --git a/gently/ui/web/static/js/admin-users.js b/gently/ui/web/static/js/admin-users.js
new file mode 100644
index 00000000..4355d4b0
--- /dev/null
+++ b/gently/ui/web/static/js/admin-users.js
@@ -0,0 +1,181 @@
+(function() {
+ const app = document.getElementById('admin-users-app');
+ const tbody = document.getElementById('admin-users-tbody');
+ const form = document.getElementById('admin-user-create');
+ const statusEl = document.getElementById('admin-users-status');
+ const currentUser = app ? app.dataset.currentUser : '';
+
+ function setStatus(text, tone) {
+ if (!statusEl) return;
+ statusEl.textContent = text || '';
+ statusEl.dataset.tone = tone || '';
+ }
+
+ async function requestJson(url, options) {
+ const response = await fetch(url, Object.assign({
+ headers: { 'Content-Type': 'application/json' },
+ }, options || {}));
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok || data.error) {
+ throw new Error(data.error || `Request failed: ${response.status}`);
+ }
+ return data;
+ }
+
+ function roleSelect(user) {
+ const select = document.createElement('select');
+ select.className = 'settings-input admin-role-select';
+ ['viewer', 'operator', 'admin'].forEach(role => {
+ const option = document.createElement('option');
+ option.value = role;
+ option.textContent = role;
+ option.selected = role === user.role;
+ select.appendChild(option);
+ });
+ select.addEventListener('change', async () => {
+ setStatus('Saving...', '');
+ try {
+ await requestJson(`/api/auth/users/${encodeURIComponent(user.username)}`, {
+ method: 'PATCH',
+ body: JSON.stringify({ role: select.value }),
+ });
+ setStatus('Role saved', 'ok');
+ await loadUsers();
+ } catch (error) {
+ select.value = user.role;
+ setStatus(error.message, 'error');
+ }
+ });
+ return select;
+ }
+
+ function passwordReset(user) {
+ const wrap = document.createElement('div');
+ wrap.className = 'admin-password-reset';
+
+ const input = document.createElement('input');
+ input.className = 'settings-input';
+ input.type = 'password';
+ input.placeholder = 'New password';
+ input.autocomplete = 'new-password';
+
+ const button = document.createElement('button');
+ button.className = 'admin-secondary-btn';
+ button.type = 'button';
+ button.textContent = 'Reset';
+ button.addEventListener('click', async () => {
+ if (!input.value) {
+ setStatus('Password required', 'error');
+ return;
+ }
+ setStatus('Saving...', '');
+ try {
+ await requestJson(`/api/auth/users/${encodeURIComponent(user.username)}`, {
+ method: 'PATCH',
+ body: JSON.stringify({ password: input.value }),
+ });
+ input.value = '';
+ setStatus('Password reset', 'ok');
+ } catch (error) {
+ setStatus(error.message, 'error');
+ }
+ });
+
+ wrap.append(input, button);
+ return wrap;
+ }
+
+ function deleteButton(user) {
+ const button = document.createElement('button');
+ button.className = 'admin-danger-btn';
+ button.type = 'button';
+ button.textContent = 'Delete';
+ button.disabled = user.username === currentUser;
+ button.title = button.disabled ? 'Cannot delete the current signed-in user' : 'Delete user';
+ button.addEventListener('click', async () => {
+ if (!window.confirm(`Delete user "${user.username}"?`)) return;
+ setStatus('Deleting...', '');
+ try {
+ await requestJson(`/api/auth/users/${encodeURIComponent(user.username)}`, {
+ method: 'DELETE',
+ });
+ setStatus('User deleted', 'ok');
+ await loadUsers();
+ } catch (error) {
+ setStatus(error.message, 'error');
+ }
+ });
+ return button;
+ }
+
+ function renderUsers(users) {
+ tbody.innerHTML = '';
+ if (!users.length) {
+ const row = document.createElement('tr');
+ const cell = document.createElement('td');
+ cell.colSpan = 4;
+ cell.className = 'admin-users-empty';
+ cell.textContent = 'No users';
+ row.appendChild(cell);
+ tbody.appendChild(row);
+ return;
+ }
+
+ users.forEach(user => {
+ const row = document.createElement('tr');
+
+ const nameCell = document.createElement('td');
+ nameCell.className = 'admin-users-name';
+ nameCell.textContent = user.username;
+ if (user.username === currentUser) {
+ const badge = document.createElement('span');
+ badge.className = 'admin-current-user';
+ badge.textContent = 'current';
+ nameCell.appendChild(badge);
+ }
+
+ const roleCell = document.createElement('td');
+ roleCell.appendChild(roleSelect(user));
+
+ const passwordCell = document.createElement('td');
+ passwordCell.appendChild(passwordReset(user));
+
+ const actionsCell = document.createElement('td');
+ actionsCell.className = 'admin-users-actions';
+ actionsCell.appendChild(deleteButton(user));
+
+ row.append(nameCell, roleCell, passwordCell, actionsCell);
+ tbody.appendChild(row);
+ });
+ }
+
+ async function loadUsers() {
+ try {
+ const data = await requestJson('/api/auth/users');
+ renderUsers(data.users || []);
+ } catch (error) {
+ setStatus(error.message, 'error');
+ }
+ }
+
+ if (form) {
+ form.addEventListener('submit', async event => {
+ event.preventDefault();
+ const payload = Object.fromEntries(new FormData(form).entries());
+ setStatus('Creating...', '');
+ try {
+ await requestJson('/api/auth/users', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+ form.reset();
+ setStatus('User created', 'ok');
+ await loadUsers();
+ } catch (error) {
+ setStatus(error.message, 'error');
+ }
+ });
+ }
+
+ document.addEventListener('DOMContentLoaded', loadUsers);
+})();
diff --git a/gently/ui/web/templates/_header.html b/gently/ui/web/templates/_header.html
index 3f113590..ccd8ea21 100644
--- a/gently/ui/web/templates/_header.html
+++ b/gently/ui/web/templates/_header.html
@@ -37,6 +37,15 @@
{% endif %}
+
+