fix(admin-welcome): credentials: include, real-content preview, refresh after mutate
This commit is contained in:
parent
2b3048f77f
commit
ecaa113c68
3 changed files with 111 additions and 14 deletions
|
|
@ -10,7 +10,7 @@ import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import duckdb
|
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 jinja2 import Environment, StrictUndefined, TemplateError, TemplateSyntaxError
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
@ -61,6 +61,10 @@ class TemplatePutRequest(BaseModel):
|
||||||
content: str = Field(..., min_length=1, max_length=200_000)
|
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)
|
@router.get("/api/welcome", response_model=WelcomeResponse)
|
||||||
async def get_welcome(
|
async def get_welcome(
|
||||||
server_url: str = Query(..., description="The server URL the analyst is bootstrapping against"),
|
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"])
|
WelcomeTemplateRepository(conn).reset(updated_by=user["email"])
|
||||||
return Response(status_code=204)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,14 @@
|
||||||
to use the OSS-shipped default.
|
to use the OSS-shipped default.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p class="status" id="status-line">
|
||||||
{% if is_override %}
|
{% if is_override %}
|
||||||
<p class="status">
|
Overridden by <strong>{{ updated_by }}</strong> on
|
||||||
Overridden by <strong>{{ updated_by }}</strong> on
|
{{ updated_at.strftime("%Y-%m-%d %H:%M UTC") if updated_at else "—" }}.
|
||||||
{{ updated_at.strftime("%Y-%m-%d %H:%M UTC") if updated_at else "—" }}.
|
|
||||||
</p>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="status">Using shipped default.</p>
|
Using shipped default.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>Available placeholders</h2>
|
<h2>Available placeholders</h2>
|
||||||
<pre class="placeholder-cheatsheet">
|
<pre class="placeholder-cheatsheet">
|
||||||
|
|
@ -50,40 +50,75 @@
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const result = $("result");
|
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 () => {
|
$("save-btn").addEventListener("click", async () => {
|
||||||
result.textContent = "Saving…";
|
result.textContent = "Saving…";
|
||||||
const r = await fetch("/api/admin/welcome-template", {
|
const r = await fetch("/api/admin/welcome-template", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
credentials: "include",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify({content: $("content").value}),
|
body: JSON.stringify({content: $("content").value}),
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
result.textContent = "Saved.";
|
result.textContent = "Saved.";
|
||||||
|
await refreshStatus();
|
||||||
} else {
|
} else {
|
||||||
const err = await r.json();
|
let detail = r.statusText;
|
||||||
result.textContent = "Error: " + (err.detail || r.statusText);
|
try { detail = (await r.json()).detail || detail; } catch {}
|
||||||
|
result.textContent = "Error: " + detail;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("reset-btn").addEventListener("click", async () => {
|
$("reset-btn").addEventListener("click", async () => {
|
||||||
if (!confirm("Reset to OSS default? Your override will be lost.")) return;
|
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) {
|
if (r.ok) {
|
||||||
result.textContent = "Reset. Reload to see the default.";
|
result.textContent = "Reset to default.";
|
||||||
|
await refreshStatus();
|
||||||
} else {
|
} 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 () => {
|
$("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) {
|
if (r.ok) {
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
$("preview").textContent = j.content;
|
$("preview").textContent = j.content;
|
||||||
$("preview").hidden = false;
|
$("preview").hidden = false;
|
||||||
|
result.textContent = "Preview rendered.";
|
||||||
} else {
|
} else {
|
||||||
const err = await r.json();
|
let detail = r.statusText;
|
||||||
result.textContent = "Render error: " + (err.detail || r.statusText);
|
try { detail = (await r.json()).detail || detail; } catch {}
|
||||||
|
result.textContent = "Render error: " + detail;
|
||||||
|
$("preview").hidden = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,39 @@ def test_get_welcome_500_includes_reset_hint_on_render_failure(seeded_app, monke
|
||||||
)
|
)
|
||||||
assert r.status_code == 500
|
assert r.status_code == 500
|
||||||
assert "/admin/welcome" in r.json()["detail"]
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue