fix(admin-welcome): credentials: include, real-content preview, refresh after mutate

This commit is contained in:
ZdenekSrotyr 2026-04-30 19:15:23 +02:00
parent 2b3048f77f
commit ecaa113c68
3 changed files with 111 additions and 14 deletions

View file

@ -10,7 +10,7 @@ import datetime
from typing import Optional
import duckdb
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from jinja2 import Environment, StrictUndefined, TemplateError, TemplateSyntaxError
from pydantic import BaseModel, Field
@ -61,6 +61,10 @@ class TemplatePutRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=200_000)
class TemplatePreviewRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=200_000)
@router.get("/api/welcome", response_model=WelcomeResponse)
async def get_welcome(
server_url: str = Query(..., description="The server URL the analyst is bootstrapping against"),
@ -117,3 +121,25 @@ async def admin_reset_template(
):
WelcomeTemplateRepository(conn).reset(updated_by=user["email"])
return Response(status_code=204)
@router.post("/api/admin/welcome-template/preview", response_model=WelcomeResponse)
async def admin_preview_template(
payload: TemplatePreviewRequest,
request: Request,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Render arbitrary template content against the live context for the
calling admin, without persisting. Used by the /admin/welcome editor's
Preview button so admins can see their edits before saving."""
from src.welcome_template import build_context
env = Environment(undefined=StrictUndefined, autoescape=False)
try:
template = env.from_string(payload.content)
ctx = build_context(conn, user=user, server_url=str(request.base_url).rstrip("/"))
rendered = template.render(**ctx)
except TemplateError as e:
raise HTTPException(status_code=400, detail=f"Template invalid: {e}")
return WelcomeResponse(content=rendered)

View file

@ -10,14 +10,14 @@
to use the OSS-shipped default.
</p>
<p class="status" id="status-line">
{% if is_override %}
<p class="status">
Overridden by <strong>{{ updated_by }}</strong> on
{{ updated_at.strftime("%Y-%m-%d %H:%M UTC") if updated_at else "—" }}.
</p>
Overridden by <strong>{{ updated_by }}</strong> on
{{ updated_at.strftime("%Y-%m-%d %H:%M UTC") if updated_at else "—" }}.
{% else %}
<p class="status">Using shipped default.</p>
Using shipped default.
{% endif %}
</p>
<h2>Available placeholders</h2>
<pre class="placeholder-cheatsheet">
@ -50,40 +50,75 @@
const $ = (id) => document.getElementById(id);
const result = $("result");
async function refreshStatus() {
const r = await fetch("/api/admin/welcome-template", {credentials: "include"});
if (!r.ok) return;
const data = await r.json();
const status = $("status-line");
if (data.content !== null) {
const when = data.updated_at
? new Date(data.updated_at).toISOString().slice(0, 16).replace("T", " ") + " UTC"
: "—";
status.innerHTML = "Overridden by <strong>" + (data.updated_by || "—") + "</strong> on " + when + ".";
$("content").value = data.content;
} else {
status.textContent = "Using shipped default.";
$("content").value = data.default;
}
}
$("save-btn").addEventListener("click", async () => {
result.textContent = "Saving…";
const r = await fetch("/api/admin/welcome-template", {
method: "PUT",
credentials: "include",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({content: $("content").value}),
});
if (r.ok) {
result.textContent = "Saved.";
await refreshStatus();
} else {
const err = await r.json();
result.textContent = "Error: " + (err.detail || r.statusText);
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch {}
result.textContent = "Error: " + detail;
}
});
$("reset-btn").addEventListener("click", async () => {
if (!confirm("Reset to OSS default? Your override will be lost.")) return;
const r = await fetch("/api/admin/welcome-template", {method: "DELETE"});
const r = await fetch("/api/admin/welcome-template", {
method: "DELETE",
credentials: "include",
});
if (r.ok) {
result.textContent = "Reset. Reload to see the default.";
result.textContent = "Reset to default.";
await refreshStatus();
} else {
result.textContent = "Error: " + r.statusText;
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch {}
result.textContent = "Error: " + detail;
}
});
$("preview-btn").addEventListener("click", async () => {
const r = await fetch("/api/welcome?server_url=" + encodeURIComponent(window.location.origin));
result.textContent = "Rendering preview…";
const r = await fetch("/api/admin/welcome-template/preview", {
method: "POST",
credentials: "include",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({content: $("content").value}),
});
if (r.ok) {
const j = await r.json();
$("preview").textContent = j.content;
$("preview").hidden = false;
result.textContent = "Preview rendered.";
} else {
const err = await r.json();
result.textContent = "Render error: " + (err.detail || r.statusText);
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch {}
result.textContent = "Render error: " + detail;
$("preview").hidden = true;
}
});
</script>

View file

@ -119,3 +119,39 @@ def test_get_welcome_500_includes_reset_hint_on_render_failure(seeded_app, monke
)
assert r.status_code == 500
assert "/admin/welcome" in r.json()["detail"]
def test_admin_preview_renders_arbitrary_content(seeded_app):
"""Preview endpoint must render the supplied content (not whatever's
stored), so the admin UI can show pre-save preview."""
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.post(
"/api/admin/welcome-template/preview",
json={"content": "# Preview {{ user.email }}"},
headers=admin,
)
assert r.status_code == 200
assert r.json()["content"].startswith("# Preview admin@test.com")
def test_preview_rejects_invalid_template(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.post(
"/api/admin/welcome-template/preview",
json={"content": "{% for x in y %}"},
headers=admin,
)
assert r.status_code == 400
def test_preview_requires_admin(seeded_app):
c = seeded_app["client"]
analyst = _auth(seeded_app["analyst_token"])
r = c.post(
"/api/admin/welcome-template/preview",
json={"content": "# x"},
headers=analyst,
)
assert r.status_code == 403