From ecaa113c6806bb1de3e072a4adda599b21fff2b4 Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Thu, 30 Apr 2026 19:15:23 +0200 Subject: [PATCH] fix(admin-welcome): credentials: include, real-content preview, refresh after mutate --- app/api/welcome.py | 28 ++++++++++++- app/web/templates/admin_welcome.html | 61 ++++++++++++++++++++++------ tests/test_welcome_template_api.py | 36 ++++++++++++++++ 3 files changed, 111 insertions(+), 14 deletions(-) diff --git a/app/api/welcome.py b/app/api/welcome.py index a593af6..dfd707d 100644 --- a/app/api/welcome.py +++ b/app/api/welcome.py @@ -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) diff --git a/app/web/templates/admin_welcome.html b/app/web/templates/admin_welcome.html index e7dc16e..9cae702 100644 --- a/app/web/templates/admin_welcome.html +++ b/app/web/templates/admin_welcome.html @@ -10,14 +10,14 @@ to use the OSS-shipped default.

+

{% if is_override %} -

- Overridden by {{ updated_by }} on - {{ updated_at.strftime("%Y-%m-%d %H:%M UTC") if updated_at else "—" }}. -

+ Overridden by {{ updated_by }} on + {{ updated_at.strftime("%Y-%m-%d %H:%M UTC") if updated_at else "—" }}. {% else %} -

Using shipped default.

+ Using shipped default. {% endif %} +

Available placeholders

@@ -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 " + (data.updated_by || "—") + " 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;
     }
   });
 
diff --git a/tests/test_welcome_template_api.py b/tests/test_welcome_template_api.py
index 86538b8..a71aadb 100644
--- a/tests/test_welcome_template_api.py
+++ b/tests/test_welcome_template_api.py
@@ -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