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:
parent
40d221f20a
commit
39146288e1
14 changed files with 1082 additions and 2 deletions
|
|
@ -12,6 +12,8 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
||||||
|
|
||||||
### Added
|
### 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.
|
- 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.
|
- `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.
|
- 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
114
app/api/setup_banner.py
Normal 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)
|
||||||
|
|
@ -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.v2_scan import router as v2_scan_router
|
||||||
from app.api.marketplaces import router as marketplaces_router
|
from app.api.marketplaces import router as marketplaces_router
|
||||||
from app.api.welcome import router as welcome_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.router import router as marketplace_server_router
|
||||||
from app.marketplace_server.git_router import make_git_wsgi_app
|
from app.marketplace_server.git_router import make_git_wsgi_app
|
||||||
from app.web.router import router as web_router
|
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(v2_scan_router)
|
||||||
app.include_router(marketplaces_router)
|
app.include_router(marketplaces_router)
|
||||||
app.include_router(welcome_router)
|
app.include_router(welcome_router)
|
||||||
|
app.include_router(setup_banner_router)
|
||||||
app.include_router(marketplace_server_router)
|
app.include_router(marketplace_server_router)
|
||||||
|
|
||||||
# Git smart-HTTP endpoint for Claude Code: /marketplace.git/*
|
# Git smart-HTTP endpoint for Claude Code: /marketplace.git/*
|
||||||
|
|
|
||||||
|
|
@ -727,13 +727,17 @@ async def setup_page(
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
):
|
):
|
||||||
"""Setup instructions for the local agent (CLI + Claude Code)."""
|
"""Setup instructions for the local agent (CLI + Claude Code)."""
|
||||||
|
from src.setup_banner import render_setup_banner
|
||||||
|
|
||||||
base_url = str(request.base_url).rstrip("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
|
banner_html = render_setup_banner(conn, user=user, server_url=base_url)
|
||||||
ctx = _build_context(
|
ctx = _build_context(
|
||||||
request,
|
request,
|
||||||
user=user,
|
user=user,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
server_url=base_url,
|
server_url=base_url,
|
||||||
agnes_version=os.environ.get("AGNES_VERSION", "dev"),
|
agnes_version=os.environ.get("AGNES_VERSION", "dev"),
|
||||||
|
banner_html=banner_html,
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(request, "install.html", ctx)
|
return templates.TemplateResponse(request, "install.html", ctx)
|
||||||
|
|
||||||
|
|
@ -911,6 +915,26 @@ async def admin_welcome_page(
|
||||||
return templates.TemplateResponse(request, "admin_welcome.html", ctx)
|
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)
|
@router.get("/tokens", response_class=HTMLResponse)
|
||||||
async def my_tokens_page(
|
async def my_tokens_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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 %}
|
{% if session.user.is_admin %}
|
||||||
<a class="app-nav-link {% if _path.startswith('/admin/marketplaces') %}is-active{% endif %}" href="/admin/marketplaces">Marketplaces</a>
|
<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">
|
<div class="app-nav-menu" id="adminNavMenu">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="app-nav-link app-nav-menu-trigger {% if _admin_active %}is-active{% endif %}"
|
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/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/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/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>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
465
app/web/templates/admin_setup_banner.html
Normal file
465
app/web/templates/admin_setup_banner.html
Normal 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 %}
|
||||||
|
|
@ -258,6 +258,21 @@
|
||||||
background: rgba(166, 227, 161, 0.08);
|
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) ── */
|
/* ── Anon sign-in banner (shown only when logged out) ── */
|
||||||
.auth-banner {
|
.auth-banner {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
|
|
@ -648,6 +663,10 @@
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
|
|
||||||
|
{% if banner_html %}
|
||||||
|
<div class="setup-banner">{{ banner_html | safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- ═══════════════ HERO ═══════════════ -->
|
<!-- ═══════════════ HERO ═══════════════ -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="hero-eyebrow">Getting started</div>
|
<div class="hero-eyebrow">Getting started</div>
|
||||||
|
|
|
||||||
30
src/db.py
30
src/db.py
|
|
@ -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}$")
|
_SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
|
||||||
|
|
||||||
SCHEMA_VERSION = 21
|
SCHEMA_VERSION = 22
|
||||||
|
|
||||||
_SYSTEM_SCHEMA = """
|
_SYSTEM_SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
|
@ -417,6 +417,16 @@ CREATE TABLE IF NOT EXISTS welcome_template (
|
||||||
updated_by VARCHAR,
|
updated_by VARCHAR,
|
||||||
CONSTRAINT singleton CHECK (id = 1)
|
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",
|
"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:
|
def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
|
||||||
"""Create tables if they don't exist. Apply migrations if schema version changed.
|
"""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) "
|
"INSERT INTO welcome_template (id, content) VALUES (1, NULL) "
|
||||||
"ON CONFLICT (id) DO NOTHING"
|
"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
|
# Fresh-install seed is handled by the unconditional
|
||||||
# _seed_core_roles call at the bottom of _ensure_schema —
|
# _seed_core_roles call at the bottom of _ensure_schema —
|
||||||
# left as a no-op branch here so the migration ladder still
|
# 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:
|
if current < 21:
|
||||||
for sql in _V20_TO_V21_MIGRATIONS:
|
for sql in _V20_TO_V21_MIGRATIONS:
|
||||||
conn.execute(sql)
|
conn.execute(sql)
|
||||||
|
if current < 22:
|
||||||
|
for sql in _V21_TO_V22_MIGRATIONS:
|
||||||
|
conn.execute(sql)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE schema_version SET version = ?, applied_at = current_timestamp",
|
"UPDATE schema_version SET version = ?, applied_at = current_timestamp",
|
||||||
[SCHEMA_VERSION],
|
[SCHEMA_VERSION],
|
||||||
|
|
|
||||||
53
src/repositories/setup_banner.py
Normal file
53
src/repositories/setup_banner.py
Normal 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
85
src/setup_banner.py
Normal 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 ""
|
||||||
104
tests/test_setup_banner_api.py
Normal file
104
tests/test_setup_banner_api.py
Normal 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
|
||||||
54
tests/test_setup_banner_migration.py
Normal file
54
tests/test_setup_banner_migration.py
Normal 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
|
||||||
80
tests/test_setup_banner_render.py
Normal file
80
tests/test_setup_banner_render.py
Normal 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 != ""
|
||||||
49
tests/test_setup_banner_repo.py
Normal file
49
tests/test_setup_banner_repo.py
Normal 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"
|
||||||
Loading…
Reference in a new issue