feat: seed script, forgot-password, hover cards, shortcuts, OpenAPI docs, runbook (phase 12)

- scripts/seed.go: idempotent demo data per §11.11 (make seed)
- forgot/reset password: SMTP-gated, one-shot TTL tokens, uniform responses
  against enumeration, sessions revoked on reset; login page link + pages
- profile hover cards on [data-user-card] elements (§11.13)
- keyboard shortcuts: g b/m/t/h navigation, / focuses search (§10)
- bulk archive endpoint (§11.9)
- hand-written OpenAPI 3.1 covering §6, served at /api/docs + yaml download
- make backup / make restore (mongodump archive via the mongo container)
- README: quick start, demo data, runbook, breaker/job operations, working
  Caddy + nginx reverse-proxy samples (WS block, client_max_body_size),
  documented later-stubs (§11.24)
- smoke.sh now exercises register → logout → login → me → board → pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 20:39:38 +02:00
parent d698f70c4f
commit c69c028028
22 changed files with 1125 additions and 15 deletions
+31
View File
@@ -2,12 +2,14 @@ package httpx
import (
"fmt"
"html"
"html/template"
"io/fs"
"net/http"
"net/url"
"strings"
"bountyboard/api"
"bountyboard/internal/domain"
"bountyboard/web"
)
@@ -155,6 +157,17 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
})
})
for _, p := range []struct{ path, tmpl, title, script string }{
{"/forgot-password", "forgot-password.html", "Forgot password", "/static/js/forgot-password.js"},
{"/reset-password", "reset-password.html", "Reset password", "/static/js/reset-password.js"},
} {
mux.HandleFunc("GET "+p.path, func(w http.ResponseWriter, r *http.Request) {
s.render(w, r, p.tmpl, &pageData{
Title: p.title, Narrow: true, Scripts: []string{p.script},
})
})
}
mux.HandleFunc("GET /change-password", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
s.render(w, r, "change-password.html", &pageData{
Title: "Change password", User: u, Narrow: true,
@@ -223,6 +236,24 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
}
// routesDocs serves the OpenAPI document and a minimal viewer (§6).
func (s *Server) routesDocs(mux *http.ServeMux) {
mux.HandleFunc("GET /api/openapi.yaml", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml")
w.Write(api.OpenAPI)
})
mux.HandleFunc("GET /api/docs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!doctype html><html lang="en"><head><meta charset="utf-8">
<title>Bounty Board API</title><link rel="stylesheet" href="/static/css/app.css">
<script src="/static/js/theme.js"></script></head>
<body><main class="container"><h1>Bounty Board API</h1>
<p><a class="btn" href="/api/openapi.yaml" download>Download openapi.yaml</a></p>
<pre style="white-space:pre-wrap;background:var(--surface);border:1px solid var(--border);padding:16px;border-radius:var(--radius)">` +
html.EscapeString(string(api.OpenAPI)) + `</pre></main></body></html>`))
})
}
func loginErrorMessage(code string) string {
switch code {
case "":