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:
@@ -24,6 +24,79 @@ func (s *Server) routesAuth(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/auth/me", s.requireAuth(http.HandlerFunc(s.handleMe)))
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/login", s.handleOIDCLogin)
|
||||
mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback)
|
||||
mux.HandleFunc("POST /api/v1/auth/forgot", s.handleForgotPassword)
|
||||
mux.HandleFunc("POST /api/v1/auth/reset", s.handleResetPassword)
|
||||
}
|
||||
|
||||
// handleForgotPassword issues a one-shot reset token by email (§11.22).
|
||||
// Active only when SMTP is configured; never reveals account existence.
|
||||
func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if s.mailer == nil || !s.mailer.Enabled() {
|
||||
writeError(w, http.StatusServiceUnavailable, "smtp_disabled",
|
||||
"password reset by email is not configured on this server")
|
||||
return
|
||||
}
|
||||
if !s.loginLimiter.Allow("forgot|" + ClientIP(r.Context()).String()) {
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts")
|
||||
return
|
||||
}
|
||||
u, err := s.store.UserByEmail(r.Context(), req.Email)
|
||||
if err == nil && u.Auth.Local != nil && !u.Disabled {
|
||||
token := auth.NewToken()
|
||||
if err := s.store.CreatePasswordReset(r.Context(), token, u.ID, time.Hour); err != nil {
|
||||
s.internalError(w, r, "create reset", err)
|
||||
return
|
||||
}
|
||||
link := s.cfg.AppBaseURL + "/reset-password?token=" + token
|
||||
go func() {
|
||||
if err := s.mailer.Send(u.Email, "Reset your Bounty Board password",
|
||||
"Use this link within one hour to set a new password:\n\n"+link+
|
||||
"\n\nIf you did not request this, ignore this email."); err != nil {
|
||||
s.log.Error("send reset mail", "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
// uniform response regardless of account existence
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "sent_if_account_exists"})
|
||||
}
|
||||
|
||||
func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < auth.MinPasswordLen {
|
||||
writeError(w, http.StatusBadRequest, "weak_password",
|
||||
fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen))
|
||||
return
|
||||
}
|
||||
userID, err := s.store.ConsumePasswordReset(r.Context(), req.Token)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_token", "this reset link is invalid or expired")
|
||||
return
|
||||
}
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "hash password", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetPassword(r.Context(), userID, hash, false); err != nil {
|
||||
s.internalError(w, r, "set password", err)
|
||||
return
|
||||
}
|
||||
if _, err := s.store.DeleteUserSessions(r.Context(), userID); err != nil {
|
||||
s.log.Warn("revoke sessions after reset", "err", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// decodeJSON reads a bounded JSON body into dst, rejecting unknown fields.
|
||||
|
||||
@@ -28,6 +28,7 @@ type Server struct {
|
||||
files *files.Store
|
||||
templates map[string]*template.Template
|
||||
oidc *auth.OIDCClient
|
||||
mailer *auth.Mailer
|
||||
loginLimiter *auth.RateLimiter
|
||||
checks []ReadinessCheck
|
||||
startedAt time.Time
|
||||
@@ -72,6 +73,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
|
||||
files: fs,
|
||||
templates: templates,
|
||||
oidc: auth.NewOIDCClient(cfg, log),
|
||||
mailer: auth.NewMailer(cfg.SMTP),
|
||||
loginLimiter: auth.NewRateLimiter(10, 15*time.Minute),
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
@@ -89,6 +91,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
|
||||
s.routesWorkResults(mux)
|
||||
s.routesMessages(mux)
|
||||
s.routesMetrics(mux)
|
||||
s.routesDocs(mux)
|
||||
mux.HandleFunc("GET /ws", s.handleWS)
|
||||
s.routesWeb(mux)
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func (s *Server) routesTasks(mux *http.ServeMux) {
|
||||
mux.Handle("POST /api/v1/tasks/{id}/publish", s.authedRole("consultant", s.handlePublishOne))
|
||||
mux.Handle("POST /api/v1/tasks/publish", s.authedRole("consultant", s.handlePublishBulk))
|
||||
mux.Handle("POST /api/v1/tasks/{id}/archive", s.authed(s.handleArchiveTask))
|
||||
mux.Handle("POST /api/v1/tasks/archive", s.authedRole("consultant", s.handleArchiveBulk))
|
||||
mux.Handle("GET /api/v1/tasks/{id}", s.requireAuth(http.HandlerFunc(s.handleTaskDetail)))
|
||||
mux.Handle("GET /api/v1/service-health", s.requireAuth(http.HandlerFunc(s.handleServiceHealth)))
|
||||
}
|
||||
@@ -405,6 +406,44 @@ func (s *Server) handleArchiveTask(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleArchiveBulk implements §11.9 bulk archive.
|
||||
func (s *Server) handleArchiveBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 || len(req.IDs) > 100 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "ids must contain 1..100 entries")
|
||||
return
|
||||
}
|
||||
u := CurrentUser(r.Context())
|
||||
archived := []string{}
|
||||
failed := map[string]string{}
|
||||
for _, id := range req.IDs {
|
||||
t, err := s.store.TaskByID(r.Context(), id)
|
||||
if err != nil {
|
||||
failed[id] = "not found"
|
||||
continue
|
||||
}
|
||||
c, err := s.store.CustomerByID(r.Context(), t.CustomerID)
|
||||
if err != nil || (!u.Roles.Admin && !c.HasConsultant(u.ID)) {
|
||||
failed[id] = "not your customer"
|
||||
continue
|
||||
}
|
||||
if err := s.store.TransitionTask(r.Context(), t, domain.StatusArchived, u.ID, "archived", nil, nil); err != nil {
|
||||
failed[id] = err.Error()
|
||||
continue
|
||||
}
|
||||
archived = append(archived, id)
|
||||
}
|
||||
if len(archived) > 0 {
|
||||
s.Publish("board", "task.archived_bulk", map[string]any{"taskIds": archived})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"archived": archived, "failed": failed})
|
||||
}
|
||||
|
||||
// handleTaskDetail is role-scoped: admins and customer consultants always;
|
||||
// developers when they are the assignee, have a claim, or the task is on the
|
||||
// public board (published/claim_requested).
|
||||
|
||||
@@ -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 "":
|
||||
|
||||
Reference in New Issue
Block a user