fix(gui): dashboard polls card + CSRF exception handler

Fix A - /dashboard/polls:
- Use get_last_msg instead of pull_subscribe (no durable consumers)
- Fix subject filter: central.meta.adapter.{name}.status
- Parse correct fields: ts and ok from status message
- Handle NotFoundError gracefully when no status exists

Fix B - CSRF exception handler:
- Add global CsrfProtectError handler in __init__.py
- Return friendly "session expired" message instead of 500
- Re-render forms with error or redirect to /login
- Update templates to display error messages

Tests:
- Add get_last_msg mocking tests for polls
- Add regression test verifying no pull_subscribe
- Add CSRF handler tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-05-17 22:29:56 +00:00
commit 9396e5dbe8
7 changed files with 283 additions and 74 deletions

View file

@ -136,6 +136,50 @@ def _create_app() -> FastAPI:
# Include routes
app.include_router(router)
# CSRF exception handler - return friendly error instead of 500
from fastapi_csrf_protect.exceptions import CsrfProtectError
from fastapi.responses import RedirectResponse
@app.exception_handler(CsrfProtectError)
async def csrf_exception_handler(request, exc: CsrfProtectError):
from fastapi_csrf_protect import CsrfProtect
csrf_protect = CsrfProtect()
csrf_token, signed_token = csrf_protect.generate_csrf_tokens()
if request.url.path == "/login":
response = templates.TemplateResponse(
request=request,
name="login.html",
context={"csrf_token": csrf_token, "error": "Your session expired. Please try again."},
)
csrf_protect.set_csrf_cookie(signed_token, response)
return response
elif request.url.path == "/setup":
response = templates.TemplateResponse(
request=request,
name="setup.html",
context={"csrf_token": csrf_token, "error": "Your session expired. Please try again."},
)
csrf_protect.set_csrf_cookie(signed_token, response)
return response
elif request.url.path == "/logout":
return RedirectResponse("/login", status_code=302)
elif request.url.path == "/change-password":
response = templates.TemplateResponse(
request=request,
name="change_password.html",
context={"csrf_token": csrf_token, "error": "Your session expired. Please try again."},
)
csrf_protect.set_csrf_cookie(signed_token, response)
return response
elif request.url.path.startswith("/adapters/"):
# Redirect back to adapters list
return RedirectResponse("/adapters", status_code=302)
else:
# Fallback: redirect to login
return RedirectResponse("/login", status_code=302)
return app

View file

@ -182,6 +182,7 @@ async def dashboard_streams(request: Request) -> HTMLResponse:
async def dashboard_polls(request: Request) -> HTMLResponse:
"""Get last poll times for each adapter."""
from central.gui.nats import get_js
from nats.js.errors import NotFoundError
templates = _get_templates()
pool = get_pool()
@ -210,43 +211,31 @@ async def dashboard_polls(request: Request) -> HTMLResponse:
else:
for name in adapter_names:
try:
# Get last message from CENTRAL_META for this adapter
sub = await js.pull_subscribe(
f"central.meta.{name}.status",
durable=f"dashboard-poll-{name}",
stream="CENTRAL_META",
msg = await js.get_last_msg(
"CENTRAL_META",
f"central.meta.adapter.{name}.status",
)
try:
msgs = await sub.fetch(1, timeout=1.0)
if msgs:
data = json.loads(msgs[0].data.decode())
last_poll = data.get("data", {}).get("time", "")
adapters.append({
"name": name,
"last_poll": last_poll,
"status": "",
"error": None,
})
else:
adapters.append({
"name": name,
"last_poll": None,
"status": None,
"error": None,
})
except Exception:
adapters.append({
"name": name,
"last_poll": None,
"status": None,
"error": None,
})
except Exception:
data = json.loads(msg.data.decode())
adapters.append({
"name": name,
"last_poll": data.get("ts"),
"status": "" if data.get("ok") else "",
"error": data.get("error") if not data.get("ok") else None,
})
except NotFoundError:
# No status message for this adapter yet
adapters.append({
"name": name,
"last_poll": None,
"status": None,
"error": "unavailable",
"error": None,
})
except Exception as e:
adapters.append({
"name": name,
"last_poll": None,
"status": "?",
"error": str(e),
})
return templates.TemplateResponse(

View file

@ -8,6 +8,10 @@
<h1>Change Password</h1>
</header>
{% if error %}
<p style="color: var(--pico-color-red-500);">{{ error }}</p>
{% endif %}
<form action="/change-password" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">

View file

@ -8,6 +8,10 @@
<h1>Login</h1>
</header>
{% if error %}
<p style="color: var(--pico-color-red-500);">{{ error }}</p>
{% endif %}
<form action="/login" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">

View file

@ -9,6 +9,10 @@
<p>Create the initial operator account to get started.</p>
</header>
{% if error %}
<p style="color: var(--pico-color-red-500);">{{ error }}</p>
{% endif %}
<form action="/setup" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">