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/gently/ui/web/accounts.py b/gently/ui/web/accounts.py
index 2a534c44..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)
@@ -118,6 +120,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/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/auth_routes.py b/gently/ui/web/routes/auth_routes.py
index e7db5500..3706844b 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()
@@ -31,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):
@@ -83,12 +108,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 +130,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/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/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 %}
+
+