147072dbb9
Phase 12 (UI/UX). The navigation bar now renders once from layout.html instead of being copied into each content template, so it is present on every authenticated page — including the domain page and its delete confirmation, which had no links at all — and the current page is highlighted via .Active rather than quietly dropping out of the list. New /account page changes the administrator's username and/or password: the current password is required and the attempt is throttled on the same limiter as the login form, so this route cannot be used to brute-force past that limit. A password change invalidates every other session while keeping the one performing it; a rename carries that session over. Backup and domain import move from a card in the middle of the domain list to their own /backup page, one card each; the handlers themselves are unchanged, only the page the import form renders its errors on. The domain page gains a "Sending server settings" card (server, port, encryption) so a client can be configured without reading the docs; 587 is listed only when SUBMISSION_ENABLE is true for this deployment, which is a deploy-time flag the panel cannot verify at runtime. Client-side (static/panel.js, no libraries): Copy buttons on the values that get carried elsewhere (DKIM record, new application credentials, server name), and the Addresses field is hidden while the address mode is wildcard, where the server ignores it. Verified in a container on the dev server: setup, login, every page's nav and active item, domain and application creation, all account-form paths including cross-session invalidation, import errors, full backup download. gofmt/vet/test/docker build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
114 lines
4.2 KiB
Go
114 lines
4.2 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
)
|
|
|
|
// templates holds the parsed page and fragment templates. Each page is parsed
|
|
// together with the shared base layout so {{ template "base" . }} works.
|
|
// Fragments (HTMX polling targets, spec 7.1) are parsed standalone, without
|
|
// the layout, so they can be swapped into an existing page as an HTML snippet
|
|
// rather than a full document. Rendering always goes through html/template,
|
|
// which auto-escapes all interpolated data regardless (spec 7.6.7).
|
|
type templates struct {
|
|
pages map[string]*template.Template
|
|
fragments map[string]*template.Template
|
|
}
|
|
|
|
// pageFiles maps a logical page name to its template files. Every page
|
|
// composes with layout.html; pages that embed a polling fragment (spec 7.1)
|
|
// list that fragment's file too, so the same {{define}} block renders both
|
|
// the initial page and the fragment's own refresh responses identically.
|
|
var pageFiles = map[string][]string{
|
|
"setup": {"templates/setup.html"},
|
|
"login": {"templates/login.html"},
|
|
"dashboard": {"templates/dashboard.html"},
|
|
"account": {"templates/account.html"},
|
|
"backup": {"templates/backup.html"},
|
|
"domain_detail": {"templates/domain_detail.html"},
|
|
"domain_delete": {"templates/domain_delete.html"},
|
|
"sendlog": {"templates/sendlog.html", "templates/sendlog_rows.html"},
|
|
"queue": {"templates/queue.html", "templates/queue_body.html"},
|
|
"logtail": {"templates/logtail.html", "templates/logtail_body.html"},
|
|
}
|
|
|
|
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
|
|
// template file, for standalone rendering by the HTMX polling endpoints.
|
|
var fragmentFiles = map[string]string{
|
|
"sendlog_rows": "templates/sendlog_rows.html",
|
|
"queue_body": "templates/queue_body.html",
|
|
"logtail_body": "templates/logtail_body.html",
|
|
}
|
|
|
|
func loadTemplates() (*templates, error) {
|
|
t := &templates{
|
|
pages: make(map[string]*template.Template),
|
|
fragments: make(map[string]*template.Template),
|
|
}
|
|
for name, files := range pageFiles {
|
|
patterns := append([]string{"templates/layout.html"}, files...)
|
|
tmpl, err := template.New("layout.html").ParseFS(assetsFS, patterns...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
|
}
|
|
t.pages[name] = tmpl
|
|
}
|
|
for name, file := range fragmentFiles {
|
|
tmpl, err := template.ParseFS(assetsFS, file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse fragment %s: %w", name, err)
|
|
}
|
|
t.fragments[name] = tmpl
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// render writes a page using the base layout. Rendering to a buffer first means
|
|
// a template error yields a clean 500 instead of a half-written page.
|
|
func (s *Server) render(w http.ResponseWriter, status int, page string, data any) {
|
|
tmpl, ok := s.tmpl.pages[page]
|
|
if !ok {
|
|
http.Error(w, "template not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
// The layout's navigation compares .Active against each item, so the key
|
|
// must exist on every authenticated page. Defaulting it here keeps a page
|
|
// that forgets it from failing to render — it simply highlights nothing.
|
|
if m, ok := data.(map[string]any); ok {
|
|
if _, has := m["Active"]; !has {
|
|
m["Active"] = ""
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "layout.html", data); err != nil {
|
|
logf("panel: render %s: %v", page, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = buf.WriteTo(w)
|
|
}
|
|
|
|
// renderFragment writes an HTMX polling fragment as a bare HTML snippet, with
|
|
// no surrounding layout (spec 7.1: fragment endpoints return HTML, not JSON).
|
|
func (s *Server) renderFragment(w http.ResponseWriter, status int, name string, data any) {
|
|
tmpl, ok := s.tmpl.fragments[name]
|
|
if !ok {
|
|
http.Error(w, "template not found", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
|
|
logf("panel: render fragment %s: %v", name, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = buf.WriteTo(w)
|
|
}
|