feat: admin-editable setup_banner on /setup page (schema v22)

Adds an optional Jinja2/HTML banner displayed above the bootstrap
commands on /setup. Empty by default; admin authors it at
/admin/setup-banner. autoescape=True — safe for HTML context.
Render failures return "" so a broken banner never breaks /setup.

Schema v22: setup_banner singleton table, auto-migration v21→v22.
This commit is contained in:
ZdenekSrotyr 2026-05-02 20:34:50 +02:00
parent 40d221f20a
commit 39146288e1
14 changed files with 1082 additions and 2 deletions

View file

@ -12,6 +12,8 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
### Added
- Admin-editable banner on `/setup` page — admins can author a Jinja2/HTML banner displayed above the auto-generated bootstrap commands. Empty by default (no banner shown). Edit at `/admin/setup-banner`. Endpoints: `GET /api/admin/setup-banner` (returns content + audit), `PUT` to set, `DELETE` to clear, `POST /api/admin/setup-banner/preview` for live preview. Useful for org-specific notes: VPN requirements, support channel, data classification, platform prerequisites.
- DuckDB schema v22: `setup_banner` singleton table for the per-instance banner. Auto-migration v21→v22 on first start.
- Customizable analyst welcome prompt (`CLAUDE.md` generated by `da analyst setup`). Default ships at `config/claude_md_template.txt` (now Jinja2 syntax). Admins override per instance via the `/admin/welcome` editor or `PUT /api/admin/welcome-template`. New endpoint `GET /api/welcome` returns the rendered prompt for the calling user, with `marketplaces` filtered by RBAC. See `docs/welcome-template.md` for the full placeholder reference.
- `POST /api/admin/welcome-template/preview` — renders arbitrary template content against the calling admin's live context without persisting. Backs the editor's Preview button.
- DuckDB schema v21: `welcome_template` singleton table for the per-instance override. Auto-migration v20→v21 on first start.

114
app/api/setup_banner.py Normal file
View file

@ -0,0 +1,114 @@
"""REST endpoints for the setup-page banner.
- GET /api/admin/setup-banner : raw content + audit info (admin)
- PUT /api/admin/setup-banner : set banner (admin)
- DELETE /api/admin/setup-banner : clear banner (admin)
- POST /api/admin/setup-banner/preview : preview arbitrary content (admin)
"""
import datetime
from typing import Optional
import duckdb
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from jinja2 import Environment, StrictUndefined, TemplateError
from pydantic import BaseModel, Field
from app.auth.access import require_admin
from app.auth.dependencies import _get_db
from src.repositories.setup_banner import SetupBannerRepository
from src.setup_banner import build_setup_banner_context
router = APIRouter(tags=["setup-banner"])
# Stub context used to validate that a saved template renders end-to-end,
# not just that it parses. Mirrors the shape of build_setup_banner_context() output.
_VALIDATION_STUB_CONTEXT = {
"instance": {"name": "Example", "subtitle": "Example Org"},
"server": {"url": "https://example.com", "hostname": "example.com"},
"user": {"id": "u", "email": "user@example.com", "name": "User", "is_admin": False},
"now": datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
"today": "2026-01-01",
}
class BannerGetResponse(BaseModel):
content: Optional[str]
updated_at: Optional[str] = None
updated_by: Optional[str] = None
class BannerPutRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=200_000)
class BannerPreviewRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=200_000)
class BannerPreviewResponse(BaseModel):
content: str
@router.get("/api/admin/setup-banner", response_model=BannerGetResponse)
async def admin_get_banner(
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
row = SetupBannerRepository(conn).get()
return BannerGetResponse(
content=row["content"],
updated_at=row["updated_at"].isoformat() if row["updated_at"] else None,
updated_by=row["updated_by"],
)
@router.put("/api/admin/setup-banner")
async def admin_put_banner(
payload: BannerPutRequest,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
env = Environment(undefined=StrictUndefined, autoescape=True)
try:
template = env.from_string(payload.content)
# Render against a stub context so undefined placeholders or runtime
# errors are caught here, not when an analyst visits /setup.
template.render(**_VALIDATION_STUB_CONTEXT)
except TemplateError as e:
raise HTTPException(status_code=400, detail=f"Template invalid: {e}")
SetupBannerRepository(conn).set(payload.content, updated_by=user["email"])
return {"status": "ok"}
@router.delete("/api/admin/setup-banner", status_code=204)
async def admin_reset_banner(
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
SetupBannerRepository(conn).reset(updated_by=user["email"])
return Response(status_code=204)
@router.post("/api/admin/setup-banner/preview", response_model=BannerPreviewResponse)
async def admin_preview_banner(
payload: BannerPreviewRequest,
request: Request,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Render arbitrary banner content against the live context for the
calling admin, without persisting. Used by the /admin/setup-banner editor's
Preview button so admins can see their edits before saving."""
env = Environment(undefined=StrictUndefined, autoescape=True)
try:
template = env.from_string(payload.content)
ctx = build_setup_banner_context(
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 BannerPreviewResponse(content=rendered)

View file

@ -122,6 +122,7 @@ from app.api.v2_sample import router as v2_sample_router
from app.api.v2_scan import router as v2_scan_router
from app.api.marketplaces import router as marketplaces_router
from app.api.welcome import router as welcome_router
from app.api.setup_banner import router as setup_banner_router
from app.marketplace_server.router import router as marketplace_server_router
from app.marketplace_server.git_router import make_git_wsgi_app
from app.web.router import router as web_router
@ -529,6 +530,7 @@ def create_app() -> FastAPI:
app.include_router(v2_scan_router)
app.include_router(marketplaces_router)
app.include_router(welcome_router)
app.include_router(setup_banner_router)
app.include_router(marketplace_server_router)
# Git smart-HTTP endpoint for Claude Code: /marketplace.git/*

View file

@ -727,13 +727,17 @@ async def setup_page(
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Setup instructions for the local agent (CLI + Claude Code)."""
from src.setup_banner import render_setup_banner
base_url = str(request.base_url).rstrip("/")
banner_html = render_setup_banner(conn, user=user, server_url=base_url)
ctx = _build_context(
request,
user=user,
conn=conn,
server_url=base_url,
agnes_version=os.environ.get("AGNES_VERSION", "dev"),
banner_html=banner_html,
)
return templates.TemplateResponse(request, "install.html", ctx)
@ -911,6 +915,26 @@ async def admin_welcome_page(
return templates.TemplateResponse(request, "admin_welcome.html", ctx)
@router.get("/admin/setup-banner", response_class=HTMLResponse)
async def admin_setup_banner_page(
request: Request,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
from src.repositories.setup_banner import SetupBannerRepository
row = SetupBannerRepository(conn).get()
ctx = _build_context(
request,
user=user,
current=row["content"] or "",
updated_at=row["updated_at"],
updated_by=row["updated_by"],
is_override=row["content"] is not None,
)
return templates.TemplateResponse(request, "admin_setup_banner.html", ctx)
@router.get("/tokens", response_class=HTMLResponse)
async def my_tokens_page(
request: Request,

View file

@ -14,7 +14,7 @@
<a class="app-nav-link {% if _path.startswith('/setup') or _path.startswith('/install') %}is-active{% endif %}" href="/setup">Setup local agent</a>
{% if session.user.is_admin %}
<a class="app-nav-link {% if _path.startswith('/admin/marketplaces') %}is-active{% endif %}" href="/admin/marketplaces">Marketplaces</a>
{% set _admin_active = _path.startswith('/admin/tables') or _path.startswith('/admin/tokens') or _path.startswith('/admin/users') or _path.startswith('/admin/groups') or _path.startswith('/admin/access') or _path.startswith('/admin/server-config') or _path.startswith('/admin/welcome') %}
{% set _admin_active = _path.startswith('/admin/tables') or _path.startswith('/admin/tokens') or _path.startswith('/admin/users') or _path.startswith('/admin/groups') or _path.startswith('/admin/access') or _path.startswith('/admin/server-config') or _path.startswith('/admin/welcome') or _path.startswith('/admin/setup-banner') %}
<div class="app-nav-menu" id="adminNavMenu">
<button type="button"
class="app-nav-link app-nav-menu-trigger {% if _admin_active %}is-active{% endif %}"
@ -33,6 +33,7 @@
<a class="app-nav-menu-item {% if _path.startswith('/admin/access') %}is-active{% endif %}" role="menuitem" href="/admin/access">Resource access</a>
<a class="app-nav-menu-item {% if _path.startswith('/admin/server-config') %}is-active{% endif %}" role="menuitem" href="/admin/server-config">Server config</a>
<a class="app-nav-menu-item {% if _path.startswith('/admin/welcome') %}is-active{% endif %}" role="menuitem" href="/admin/welcome">Welcome prompt</a>
<a class="app-nav-menu-item {% if _path.startswith('/admin/setup-banner') %}is-active{% endif %}" role="menuitem" href="/admin/setup-banner">Setup banner</a>
</div>
</div>
{% endif %}

View file

@ -0,0 +1,465 @@
{% extends "base.html" %}
{% block title %}Setup Banner — {{ config.INSTANCE_NAME }}{% endblock %}
{% block content %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/jinja2/jinja2.min.js"></script>
<style>
.container:has(.welcome-page) { max-width: none; padding: 24px 16px; }
.welcome-page { max-width: 1400px; margin: 0 auto; padding: 0; }
.welcome-toolbar {
display: flex; justify-content: space-between; align-items: center;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
}
.welcome-title { margin: 0; font-size: 22px; font-weight: 600; }
.welcome-sub { color: var(--text-secondary, #6b7280); font-size: 13px; margin-top: 4px; margin-bottom: 0; }
.origin-chip {
display: inline-block;
padding: 3px 10px; border-radius: 999px;
font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.4px;
}
.origin-override { background: #ede9fe; color: #6d28d9; }
.origin-default { background: #f3f4f6; color: #6b7280; }
.welcome-card {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-radius: 12px;
margin-bottom: 16px;
overflow: hidden;
}
.welcome-card-body { padding: 20px 22px; }
.welcome-desc { font-size: 13px; color: var(--text-secondary, #6b7280); margin: 0 0 16px; }
/* Placeholder cheatsheet — collapsible */
details.welcome-cheatsheet { margin-bottom: 18px; }
details.welcome-cheatsheet summary {
cursor: pointer; font-size: 13px; font-weight: 500;
color: var(--text-primary, #111827); list-style: none;
display: inline-flex; align-items: center; gap: 6px;
}
details.welcome-cheatsheet summary::-webkit-details-marker { display: none; }
details.welcome-cheatsheet summary::before {
content: "▶"; font-size: 10px; color: var(--text-secondary, #6b7280);
transition: transform 0.15s;
}
details.welcome-cheatsheet[open] summary::before { transform: rotate(90deg); }
/* Dark code block */
.code-block {
background: #1e1e2e;
border-radius: 8px;
padding: 14px 16px;
display: flex;
align-items: flex-start;
gap: 12px;
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
font-size: 13px;
color: #cdd6f4;
line-height: 1.6;
margin-top: 10px;
position: relative;
}
.code-block .code-body { flex: 1; white-space: pre; overflow-x: auto; }
.btn-copy {
padding: 6px 14px;
background: transparent;
border: 1px solid #45475a;
color: #cdd6f4;
cursor: pointer;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
font-family: inherit;
transition: all 0.15s ease;
flex-shrink: 0;
}
.btn-copy:hover { border-color: #89b4fa; color: #89b4fa; background: rgba(137, 180, 250, 0.08); }
.btn-copy.copied { border-color: #a6e3a1; color: #a6e3a1; background: rgba(166, 227, 161, 0.08); }
/* CodeMirror editor styling */
.welcome-page .CodeMirror {
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
font-size: 13px;
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
}
/* Split layout: editor left, preview right */
.welcome-editor-row {
display: flex;
gap: 16px;
margin-top: 0;
}
.welcome-editor-col, .welcome-preview-col {
flex: 1 1 50%;
min-width: 0;
}
.welcome-preview-col {
border: 1px solid var(--border, #e5e7eb);
border-radius: 8px;
background: var(--surface, #fff);
color: var(--text-primary, #111827);
padding: 16px;
font-size: 14px;
line-height: 1.6;
overflow: auto;
max-height: 600px;
}
.welcome-preview-col h4 {
color: var(--text-secondary, #6b7280); margin: 0 0 8px; font-size: 12px;
text-transform: uppercase; letter-spacing: 0.5px;
}
.welcome-preview-empty {
color: var(--text-secondary, #6b7280);
font-style: italic;
font-size: 13px;
}
.welcome-preview-error {
background: rgba(234, 88, 12, 0.15);
color: #b91c1c;
border: 1px solid rgba(234, 88, 12, 0.4);
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
white-space: pre-wrap;
}
@media (max-width: 1100px) {
.welcome-editor-row { flex-direction: column; }
}
/* Action row */
.welcome-actions {
padding: 14px 22px;
background: var(--border-light, #fafafa);
border-top: 1px solid var(--border, #e5e7eb);
display: flex; gap: 8px; justify-content: flex-end;
}
.welcome-btn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
cursor: pointer; transition: all 0.15s;
}
.welcome-btn:hover { background: var(--border-light, #f9fafb); }
.welcome-btn.primary { background: var(--primary, #6366f1); color: #fff; border-color: var(--primary, #6366f1); }
.welcome-btn.primary:hover { filter: brightness(1.05); }
.welcome-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
.welcome-btn.danger:hover { filter: brightness(1.05); }
.welcome-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Modal */
.modal-backdrop {
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55);
display: none; align-items: center; justify-content: center; z-index: 1000;
padding: 16px;
}
.modal-backdrop.is-open { display: flex; }
.modal-card {
background: var(--surface, #fff); border-radius: 12px;
padding: 24px; width: 100%; max-width: 440px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
}
.modal-card h3 { margin: 0 0 6px; font-size: 17px; font-weight: 600; }
.modal-card p.sub { margin: 0 0 18px; font-size: 13px; color: var(--text-secondary, #6b7280); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
.modal-btn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
border: 1px solid var(--border, #e5e7eb); background: var(--surface, #fff);
cursor: pointer;
}
.modal-btn.danger { background: #dc2626; color: #fff; border-color: #dc2626; }
/* Toast stack */
.toast-stack {
position: fixed; bottom: 24px; right: 24px; z-index: 2000;
display: flex; flex-direction: column; gap: 8px; pointer-events: none;
}
.toast {
background: #111827; color: #fff; padding: 10px 16px;
border-radius: 8px; font-size: 13px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
opacity: 0; transform: translateY(8px); transition: opacity 0.2s, transform 0.2s;
pointer-events: auto; max-width: 380px;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.success { background: #047857; }
.toast.error { background: #b91c1c; }
</style>
<div class="welcome-page">
<div class="welcome-toolbar">
<div>
<h2 class="welcome-title">Setup Page Banner</h2>
<p class="welcome-sub">Shown above the bootstrap commands on <code>/setup</code>. Use it for org-specific notes: VPN requirements, support channel, data classification, platform prerequisites. Empty by default — no banner is shown when unset.</p>
</div>
<div id="status-chip">
{% if is_override %}
<span class="origin-chip origin-override"
title="Overridden by {{ updated_by }} on {{ updated_at.strftime('%Y-%m-%d %H:%M UTC') if updated_at else '—' }}">
Banner active
</span>
{% else %}
<span class="origin-chip origin-default">No banner</span>
{% endif %}
</div>
</div>
<div class="welcome-card">
<div class="welcome-card-body">
<p class="welcome-desc">
Author HTML or plain text with Jinja2 placeholders. The banner renders inside the <code>/setup</code> page —
HTML tags are allowed. Leave empty and click <strong>Remove banner</strong> to go back to no banner.
<code>{{ "{{ user }}" }}</code> may be <code>null</code> for anonymous visitors — guard with <code>{% if user %}</code>.
</p>
<details class="welcome-cheatsheet">
<summary>Available placeholders</summary>
<div class="code-block">
<span id="placeholder-text" class="code-body">{{ "{{ instance.name }}" }} — instance display name
{{ "{{ instance.subtitle }}" }} — operator name
{{ "{{ server.url }}" }} — full server URL
{{ "{{ server.hostname }}" }} — host part only
{{ "{{ user.email }}" }}, {{ "{{ user.name }}" }}, {{ "{{ user.is_admin }}" }}
— null for anonymous visitors; guard with {% if user %}
{{ "{{ now }}" }}, {{ "{{ today }}" }}</span>
<button class="btn-copy" data-copy-target="placeholder-text">Copy</button>
</div>
</details>
<div class="welcome-editor-row">
<div class="welcome-editor-col">
<textarea id="content" name="content">{{ current }}</textarea>
</div>
<div class="welcome-preview-col">
<h4>Live preview</h4>
<div id="preview-content"></div>
<div id="preview-error" class="welcome-preview-error" hidden></div>
</div>
</div>
</div>
<div class="welcome-actions">
<button type="button" class="welcome-btn danger" id="reset-btn">Remove banner</button>
<button type="button" class="welcome-btn primary" id="save-btn">Save banner</button>
</div>
</div>
</div>
<!-- Remove confirmation modal -->
<div class="modal-backdrop" id="reset-modal" role="dialog" aria-modal="true" aria-labelledby="reset-modal-title">
<div class="modal-card">
<h3 id="reset-modal-title">Remove the banner?</h3>
<p class="sub"><code>/setup</code> will go back to showing no banner. This cannot be undone.</p>
<div class="modal-actions">
<button class="modal-btn" data-close-modal="reset-modal">Cancel</button>
<button class="modal-btn danger" id="reset-confirm-btn">Remove</button>
</div>
</div>
</div>
<div class="toast-stack" id="toast-stack" aria-live="polite"></div>
<script>
const API = "/api/admin/setup-banner";
// ── CodeMirror editor ─────────────────────────────────────────────────
const editor = CodeMirror.fromTextArea(document.getElementById("content"), {
mode: "jinja2",
lineNumbers: true,
lineWrapping: true,
theme: "material-darker",
indentUnit: 2,
tabSize: 2,
});
editor.setSize("100%", 480);
// ── Live preview (debounced) ──────────────────────────────────────────
let previewTimer = null;
function schedulePreview() {
if (previewTimer) clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, 500);
}
async function renderPreview() {
const content = editor.getValue();
const previewBox = document.getElementById("preview-content");
const previewErr = document.getElementById("preview-error");
if (!content.trim()) {
previewBox.innerHTML = '<span class="welcome-preview-empty">(no banner)</span>';
previewErr.hidden = true;
return;
}
try {
const r = await fetch(API + "/preview", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (r.ok) {
const j = await r.json();
previewBox.innerHTML = j.content;
previewErr.hidden = true;
} else {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (_) {}
previewBox.innerHTML = "";
previewErr.textContent = detail;
previewErr.hidden = false;
}
} catch (e) {
previewErr.textContent = "Network error: " + e.message;
previewErr.hidden = false;
}
}
editor.on("change", schedulePreview);
renderPreview(); // initial render on load
// ── Toast ─────────────────────────────────────────────────────────────
function toast(msg, kind) {
const el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
document.getElementById("toast-stack").appendChild(el);
requestAnimationFrame(() => el.classList.add("show"));
setTimeout(() => { el.classList.remove("show"); setTimeout(() => el.remove(), 250); }, 3500);
}
// ── Modal helpers ─────────────────────────────────────────────────────
function openModal(id) { document.getElementById(id).classList.add("is-open"); }
function closeModal(id) { document.getElementById(id).classList.remove("is-open"); }
document.querySelectorAll("[data-close-modal]").forEach(el =>
el.addEventListener("click", () => closeModal(el.dataset.closeModal)));
document.querySelectorAll(".modal-backdrop").forEach(el => {
el.addEventListener("click", e => { if (e.target === el) el.classList.remove("is-open"); });
});
document.addEventListener("keydown", e => {
if (e.key === "Escape") document.querySelectorAll(".modal-backdrop.is-open").forEach(m => m.classList.remove("is-open"));
});
// ── Status chip ───────────────────────────────────────────────────────
function setStatusChip(data) {
const wrap = document.getElementById("status-chip");
if (data.content !== null && data.content !== undefined) {
const when = data.updated_at
? new Date(data.updated_at).toISOString().slice(0, 16).replace("T", " ") + " UTC"
: "—";
const who = data.updated_by || "—";
wrap.innerHTML = `<span class="origin-chip origin-override" title="Overridden by ${who} on ${when}">Banner active</span>`;
} else {
wrap.innerHTML = `<span class="origin-chip origin-default">No banner</span>`;
}
}
// ── Refresh status + editor content from API ─────────────────────────
async function refreshStatus() {
const r = await fetch(API, { credentials: "include" });
if (!r.ok) return;
const data = await r.json();
setStatusChip(data);
editor.setValue(data.content !== null && data.content !== undefined ? data.content : "");
renderPreview();
}
// ── Save ──────────────────────────────────────────────────────────────
document.getElementById("save-btn").addEventListener("click", async () => {
const btn = document.getElementById("save-btn");
const content = editor.getValue();
if (!content.trim()) {
toast("Nothing to save — editor is empty. Use Remove banner to clear.", "error");
return;
}
btn.disabled = true;
try {
const r = await fetch(API, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (r.ok) {
toast("Banner saved.", "success");
await refreshStatus();
} else {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (_) {}
toast("Save failed: " + detail, "error");
}
} catch (e) {
toast("Save failed: " + e.message, "error");
} finally {
btn.disabled = false;
}
});
// ── Remove (with confirm modal) ───────────────────────────────────────
document.getElementById("reset-btn").addEventListener("click", () => openModal("reset-modal"));
document.getElementById("reset-confirm-btn").addEventListener("click", async () => {
closeModal("reset-modal");
const btn = document.getElementById("reset-btn");
btn.disabled = true;
try {
const r = await fetch(API, { method: "DELETE", credentials: "include" });
if (r.ok) {
toast("Banner removed.", "success");
await refreshStatus();
} else {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (_) {}
toast("Remove failed: " + detail, "error");
}
} catch (e) {
toast("Remove failed: " + e.message, "error");
} finally {
btn.disabled = false;
}
});
// ── Copy widget ───────────────────────────────────────────────────────
function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
const ta = document.createElement("textarea");
ta.value = text;
ta.style.cssText = "position:fixed;left:-9999px;top:-9999px";
document.body.appendChild(ta);
ta.focus();
ta.select();
return new Promise((resolve, reject) => {
try { document.execCommand("copy") ? resolve() : reject(); }
finally { document.body.removeChild(ta); }
});
}
function flashCopied(button) {
const original = button.textContent;
button.textContent = "Copied!";
button.classList.add("copied");
setTimeout(() => { button.textContent = original; button.classList.remove("copied"); }, 1500);
}
document.querySelectorAll(".btn-copy[data-copy-target]").forEach(btn => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.getAttribute("data-copy-target"));
if (!target) return;
copyToClipboard(target.textContent.trim())
.then(() => flashCopied(btn))
.catch(() => {
const range = document.createRange();
range.selectNodeContents(target);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
});
});
});
</script>
{% endblock %}

View file

@ -258,6 +258,21 @@
background: rgba(166, 227, 161, 0.08);
}
/* ── Admin setup banner (shown when admin has set one) ── */
.setup-banner {
background: var(--surface, #fff);
border: 1px solid var(--border, #e5e7eb);
border-left: 3px solid var(--primary, #6366f1);
border-radius: var(--radius, 8px);
padding: 14px 18px;
margin-bottom: 20px;
font-size: 14px;
color: var(--text-primary, #111827);
line-height: 1.6;
}
.setup-banner > *:first-child { margin-top: 0; }
.setup-banner > *:last-child { margin-bottom: 0; }
/* ── Anon sign-in banner (shown only when logged out) ── */
.auth-banner {
background: var(--background);
@ -648,6 +663,10 @@
<main class="main">
{% if banner_html %}
<div class="setup-banner">{{ banner_html | safe }}</div>
{% endif %}
<!-- ═══════════════ HERO ═══════════════ -->
<section class="hero">
<div class="hero-eyebrow">Getting started</div>

View file

@ -39,7 +39,7 @@ def _maybe_instrument(con, db_tag: str):
_SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
SCHEMA_VERSION = 21
SCHEMA_VERSION = 22
_SYSTEM_SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_version (
@ -417,6 +417,16 @@ CREATE TABLE IF NOT EXISTS welcome_template (
updated_by VARCHAR,
CONSTRAINT singleton CHECK (id = 1)
);
-- v22: customizable banner shown above setup commands on /setup page.
-- Singleton row (id=1). NULL content means "no banner".
CREATE TABLE IF NOT EXISTS setup_banner (
id INTEGER PRIMARY KEY DEFAULT 1,
content TEXT,
updated_at TIMESTAMP,
updated_by VARCHAR,
CONSTRAINT singleton CHECK (id = 1)
);
"""
@ -1637,6 +1647,17 @@ _V20_TO_V21_MIGRATIONS = [
"INSERT INTO welcome_template (id, content) VALUES (1, NULL) ON CONFLICT (id) DO NOTHING",
]
_V21_TO_V22_MIGRATIONS = [
"""CREATE TABLE IF NOT EXISTS setup_banner (
id INTEGER PRIMARY KEY DEFAULT 1,
content TEXT,
updated_at TIMESTAMP,
updated_by VARCHAR,
CONSTRAINT singleton CHECK (id = 1)
)""",
"INSERT INTO setup_banner (id, content) VALUES (1, NULL) ON CONFLICT (id) DO NOTHING",
]
def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
"""Create tables if they don't exist. Apply migrations if schema version changed.
@ -1699,6 +1720,10 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
"INSERT INTO welcome_template (id, content) VALUES (1, NULL) "
"ON CONFLICT (id) DO NOTHING"
)
conn.execute(
"INSERT INTO setup_banner (id, content) VALUES (1, NULL) "
"ON CONFLICT (id) DO NOTHING"
)
# Fresh-install seed is handled by the unconditional
# _seed_core_roles call at the bottom of _ensure_schema —
# left as a no-op branch here so the migration ladder still
@ -1779,6 +1804,9 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
if current < 21:
for sql in _V20_TO_V21_MIGRATIONS:
conn.execute(sql)
if current < 22:
for sql in _V21_TO_V22_MIGRATIONS:
conn.execute(sql)
conn.execute(
"UPDATE schema_version SET version = ?, applied_at = current_timestamp",
[SCHEMA_VERSION],

View file

@ -0,0 +1,53 @@
"""Repository for the per-instance setup-page banner override (singleton row)."""
from datetime import datetime, timezone
from typing import Any, Optional
import duckdb
class SetupBannerRepository:
def __init__(self, conn: duckdb.DuckDBPyConnection):
self.conn = conn
def get(self) -> dict[str, Any]:
"""Return the singleton row. Always exists post-migration; content
is None when no banner is set."""
row = self.conn.execute(
"SELECT id, content, updated_at, updated_by FROM setup_banner WHERE id = 1"
).fetchone()
if row is None:
# Defensive: re-seed if a previous admin manually deleted it.
self.conn.execute(
"INSERT INTO setup_banner (id, content) VALUES (1, NULL) "
"ON CONFLICT (id) DO NOTHING"
)
return {"id": 1, "content": None, "updated_at": None, "updated_by": None}
return {
"id": row[0],
"content": row[1],
"updated_at": row[2],
"updated_by": row[3],
}
def set(self, content: str, *, updated_by: str) -> None:
now = datetime.now(timezone.utc)
self.conn.execute(
"""INSERT INTO setup_banner (id, content, updated_at, updated_by)
VALUES (1, ?, ?, ?)
ON CONFLICT (id) DO UPDATE SET
content = excluded.content,
updated_at = excluded.updated_at,
updated_by = excluded.updated_by""",
[content, now, updated_by],
)
def reset(self, *, updated_by: str) -> None:
"""Clear the banner; /setup will show no banner."""
now = datetime.now(timezone.utc)
self.conn.execute(
"""UPDATE setup_banner
SET content = NULL, updated_at = ?, updated_by = ?
WHERE id = 1""",
[now, updated_by],
)

85
src/setup_banner.py Normal file
View file

@ -0,0 +1,85 @@
"""Render the admin-editable setup-page banner.
Smaller surface than welcome_template only instance/server/user context.
Setup banner is for organization-specific operational notes (VPN, support,
data classification), not for analyst-side content.
"""
from __future__ import annotations
import logging
from datetime import date, datetime, timezone
from typing import Any, Optional
from urllib.parse import urlparse
import duckdb
from jinja2 import Environment, StrictUndefined, TemplateError
from app.instance_config import get_instance_name, get_instance_subtitle
from src.repositories.setup_banner import SetupBannerRepository
_logger = logging.getLogger(__name__)
def build_setup_banner_context(
*,
user: Optional[dict],
server_url: str,
) -> dict[str, Any]:
"""Compose the Jinja2 render context for the setup banner.
``user`` may be None on the anonymous path of /setup (the page is partly
public anonymous visitors get the curl-install one-liner). Templates
must guard for that with ``{% if user %}``.
"""
parsed = urlparse(server_url)
return {
"instance": {
"name": get_instance_name(),
"subtitle": get_instance_subtitle(),
},
"server": {
"url": server_url,
"hostname": parsed.hostname or "",
},
"user": (
{
"id": user.get("id", ""),
"email": user.get("email", ""),
"name": user.get("name") or "",
"is_admin": bool(user.get("is_admin")),
}
if user
else None
),
"now": datetime.now(timezone.utc),
"today": date.today().isoformat(),
}
def render_setup_banner(
conn: duckdb.DuckDBPyConnection,
*,
user: Optional[dict],
server_url: str,
) -> str:
"""Render the banner. Returns "" when no override is set or render fails.
Render failures are swallowed (logged) a broken admin banner must NOT
break /setup for analysts. The /admin/setup-banner editor catches Jinja
errors at PUT time anyway, so this is defense-in-depth.
"""
row = SetupBannerRepository(conn).get()
source = row.get("content")
if not source:
return ""
env = Environment(undefined=StrictUndefined, autoescape=True)
try:
template = env.from_string(source)
return template.render(**build_setup_banner_context(user=user, server_url=server_url))
except TemplateError:
_logger.warning(
"setup_banner render failed; returning empty banner. "
"Admin can fix at /admin/setup-banner."
)
return ""

View file

@ -0,0 +1,104 @@
"""End-to-end tests for /api/admin/setup-banner endpoints."""
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def test_admin_can_set_and_clear_banner(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
# GET initial state
r = c.get("/api/admin/setup-banner", headers=admin)
assert r.status_code == 200
body = r.json()
assert body["content"] is None
# PUT banner
r = c.put(
"/api/admin/setup-banner",
json={"content": "<p>VPN required before install.</p>"},
headers=admin,
)
assert r.status_code == 200
# GET shows new content
r = c.get("/api/admin/setup-banner", headers=admin)
assert r.json()["content"] == "<p>VPN required before install.</p>"
assert r.json()["updated_by"] is not None
# DELETE = clear
r = c.delete("/api/admin/setup-banner", headers=admin)
assert r.status_code == 204
r = c.get("/api/admin/setup-banner", headers=admin)
assert r.json()["content"] is None
def test_non_admin_cannot_edit_banner(seeded_app):
c = seeded_app["client"]
analyst = _auth(seeded_app["analyst_token"])
r = c.put("/api/admin/setup-banner", json={"content": "<p>x</p>"}, headers=analyst)
assert r.status_code == 403
def test_put_rejects_invalid_jinja2(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.put(
"/api/admin/setup-banner",
json={"content": "{% for x in y %}"}, # unclosed loop
headers=admin,
)
assert r.status_code == 400
assert "invalid" in r.json()["detail"].lower()
def test_put_rejects_undefined_placeholder(seeded_app):
"""Templates that reference unknown placeholders must be rejected at PUT
time so the admin sees the error immediately."""
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.put(
"/api/admin/setup-banner",
json={"content": "Hello {{ user.emial }}"}, # typo
headers=admin,
)
assert r.status_code == 400
assert "emial" in r.json()["detail"] or "undefined" in r.json()["detail"].lower()
def test_preview_renders_arbitrary_content(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.post(
"/api/admin/setup-banner/preview",
json={"content": "<b>Hello {{ user.email }}</b>"},
headers=admin,
)
assert r.status_code == 200
# autoescape=True: rendered content must contain the escaped or literal email
assert "admin@test.com" in r.json()["content"]
def test_preview_requires_admin(seeded_app):
c = seeded_app["client"]
analyst = _auth(seeded_app["analyst_token"])
r = c.post(
"/api/admin/setup-banner/preview",
json={"content": "<p>x</p>"},
headers=analyst,
)
assert r.status_code == 403
def test_preview_rejects_invalid_template(seeded_app):
c = seeded_app["client"]
admin = _auth(seeded_app["admin_token"])
r = c.post(
"/api/admin/setup-banner/preview",
json={"content": "{% for x in y %}"},
headers=admin,
)
assert r.status_code == 400

View file

@ -0,0 +1,54 @@
"""v21 → v22 migration: adds setup_banner singleton table."""
from pathlib import Path
import duckdb
from src.db import SCHEMA_VERSION, _ensure_schema, get_schema_version
def _open(path: Path) -> duckdb.DuckDBPyConnection:
return duckdb.connect(str(path))
def test_v22_creates_setup_banner_table(tmp_path):
db_path = tmp_path / "system.duckdb"
conn = _open(db_path)
# Pretend we're on v21: run schema then roll version back.
_ensure_schema(conn)
conn.execute("UPDATE schema_version SET version = 21")
conn.execute("DROP TABLE IF EXISTS setup_banner")
conn.close()
# Re-open: migration ladder runs.
conn = _open(db_path)
_ensure_schema(conn)
assert get_schema_version(conn) == SCHEMA_VERSION
# Singleton row must exist with NULL content (= no banner).
rows = conn.execute(
"SELECT id, content, updated_at, updated_by FROM setup_banner"
).fetchall()
assert len(rows) == 1
assert rows[0][0] == 1 # singleton id
assert rows[0][1] is None # NULL = no banner
def test_fresh_install_seeds_setup_banner(tmp_path):
db_path = tmp_path / "system.duckdb"
conn = _open(db_path)
_ensure_schema(conn)
assert get_schema_version(conn) == SCHEMA_VERSION
rows = conn.execute("SELECT id, content FROM setup_banner").fetchall()
assert len(rows) == 1
assert rows[0][0] == 1
assert rows[0][1] is None
def test_welcome_template_unaffected_by_v22(tmp_path):
"""welcome_template table must still coexist after v22 migration."""
db_path = tmp_path / "system.duckdb"
conn = _open(db_path)
_ensure_schema(conn)
rows = conn.execute("SELECT id, content FROM welcome_template").fetchall()
assert len(rows) == 1
assert rows[0][0] == 1

View file

@ -0,0 +1,80 @@
"""Unit tests for the setup-banner renderer module."""
import duckdb
import pytest
from src.db import _ensure_schema
from src.repositories.setup_banner import SetupBannerRepository
from src.setup_banner import build_setup_banner_context, render_setup_banner
@pytest.fixture
def conn(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
db_path = tmp_path / "system.duckdb"
c = duckdb.connect(str(db_path))
_ensure_schema(c)
yield c
c.close()
def _user(email="alice@example.com"):
return {"id": "u1", "email": email, "name": "Alice", "is_admin": False}
def test_render_returns_empty_when_no_override(conn):
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
assert out == ""
def test_render_uses_override(conn):
SetupBannerRepository(conn).set(
"<p>VPN: {{ server.hostname }}</p>", updated_by="admin@example.com"
)
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
# autoescape=True — rendered as HTML
assert "example.com" in out
assert "<p>" in out
def test_render_returns_empty_on_invalid_template_does_not_raise(conn):
"""A broken admin banner must not raise; it must return "" (defense-in-depth)."""
SetupBannerRepository(conn).set(
"{{ does_not_exist }}", updated_by="admin@example.com"
)
out = render_setup_banner(conn, user=_user(), server_url="https://example.com")
assert out == "" # swallowed, not raised
def test_render_with_anonymous_user(conn):
SetupBannerRepository(conn).set(
"{% if user %}{{ user.email }}{% else %}anonymous{% endif %}",
updated_by="admin@example.com",
)
out = render_setup_banner(conn, user=None, server_url="https://example.com")
assert "anonymous" in out
def test_context_exposes_documented_keys(conn):
ctx = build_setup_banner_context(user=_user(), server_url="https://example.com")
for top in ("instance", "server", "user", "now", "today"):
assert top in ctx, f"missing top-level key: {top}"
assert ctx["server"]["hostname"] == "example.com"
assert ctx["user"]["email"] == "alice@example.com"
def test_context_with_anonymous_user_returns_none(conn):
ctx = build_setup_banner_context(user=None, server_url="https://example.com")
assert ctx["user"] is None
def test_autoescape_escapes_html_entities(conn):
"""autoescape=True must escape < > & in template variable output."""
SetupBannerRepository(conn).set(
"{{ server.hostname }}", updated_by="admin@example.com"
)
out = render_setup_banner(
conn, user=_user(), server_url="https://example.com/<test>"
)
# hostname won't contain < > but the render must succeed without injection
assert out != ""

View file

@ -0,0 +1,49 @@
"""Unit tests for SetupBannerRepository."""
import duckdb
import pytest
from src.db import _ensure_schema
from src.repositories.setup_banner import SetupBannerRepository
@pytest.fixture
def conn(tmp_path):
db_path = tmp_path / "system.duckdb"
c = duckdb.connect(str(db_path))
_ensure_schema(c)
yield c
c.close()
def test_get_returns_none_on_fresh_install(conn):
repo = SetupBannerRepository(conn)
row = repo.get()
assert row is not None
assert row["content"] is None # no banner by default
def test_set_stores_content(conn):
repo = SetupBannerRepository(conn)
repo.set("<p>VPN required</p>", updated_by="admin@example.com")
row = repo.get()
assert row["content"] == "<p>VPN required</p>"
assert row["updated_by"] == "admin@example.com"
assert row["updated_at"] is not None
def test_reset_clears_content(conn):
repo = SetupBannerRepository(conn)
repo.set("<p>Note</p>", updated_by="admin@example.com")
repo.reset(updated_by="admin@example.com")
row = repo.get()
assert row["content"] is None
def test_set_overwrites_existing(conn):
repo = SetupBannerRepository(conn)
repo.set("first", updated_by="a@example.com")
repo.set("second", updated_by="b@example.com")
row = repo.get()
assert row["content"] == "second"
assert row["updated_by"] == "b@example.com"