b1c9a42810
- aggregations over the immutable bountyAwards ledger: totals, weekly buckets ($dateTrunc), per-developer and per-customer groupings - timeline-derived: approval rate, time logged, assigned→approved lead time, imported→published atomization lead time, open board depth - developer + consultant dashboards (admin = global consultant view), date-range filters, CSV export of the ledger - leaderboard (top 10 by bounty) honoring the new leaderboardOptOut profile setting - hand-rolled SVG line and bar charts (~150 lines, no chart library) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
251 lines
7.9 KiB
Go
251 lines
7.9 KiB
Go
package httpx
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"bountyboard/internal/domain"
|
|
"bountyboard/web"
|
|
)
|
|
|
|
// pageData is the payload every template receives.
|
|
type pageData struct {
|
|
Title string
|
|
Active string // nav highlight key
|
|
User *domain.User
|
|
DefaultTheme string
|
|
Narrow bool
|
|
OIDCEnabled bool
|
|
Error string
|
|
HasLocalAuth bool
|
|
Scripts []string
|
|
Data map[string]any // page-specific extras
|
|
}
|
|
|
|
var templateFuncs = template.FuncMap{
|
|
"initials": func(name string) string {
|
|
parts := strings.Fields(name)
|
|
switch {
|
|
case len(parts) >= 2:
|
|
return strings.ToUpper(string([]rune(parts[0])[:1]) + string([]rune(parts[1])[:1]))
|
|
case len(parts) == 1 && len(parts[0]) > 0:
|
|
return strings.ToUpper(string([]rune(parts[0])[:1]))
|
|
default:
|
|
return "?"
|
|
}
|
|
},
|
|
}
|
|
|
|
// parseTemplates builds one template set per page (layout + page content).
|
|
func parseTemplates() (map[string]*template.Template, error) {
|
|
pages, err := fs.Glob(web.FS, "templates/*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]*template.Template)
|
|
for _, page := range pages {
|
|
name := strings.TrimPrefix(page, "templates/")
|
|
if name == "layout.html" {
|
|
continue
|
|
}
|
|
t, err := template.New(name).Funcs(templateFuncs).
|
|
ParseFS(web.FS, "templates/layout.html", page)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", page, err)
|
|
}
|
|
out[name] = t
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, data *pageData) {
|
|
t, ok := s.templates[page]
|
|
if !ok {
|
|
s.internalError(w, r, "render", fmt.Errorf("unknown template %q", page))
|
|
return
|
|
}
|
|
if data == nil {
|
|
data = &pageData{}
|
|
}
|
|
if data.DefaultTheme == "" {
|
|
data.DefaultTheme = "light"
|
|
if data.User != nil && data.User.Settings.Theme != "" {
|
|
data.DefaultTheme = data.User.Settings.Theme
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
|
s.log.Error("execute template", "page", page, "err", err)
|
|
}
|
|
}
|
|
|
|
// pageUser resolves the session for an HTML page; returns nil when not
|
|
// logged in (no error response — pages redirect instead).
|
|
func (s *Server) pageUser(r *http.Request) *domain.User {
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil || c.Value == "" {
|
|
return nil
|
|
}
|
|
sess, err := s.store.SessionByToken(r.Context(), c.Value)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
u, err := s.store.UserByID(r.Context(), sess.UserID)
|
|
if err != nil || u.Disabled {
|
|
return nil
|
|
}
|
|
return u
|
|
}
|
|
|
|
// page wraps an HTML handler that requires login: redirects anonymous
|
|
// visitors to /login?next=… and forces pending password changes through
|
|
// /change-password.
|
|
func (s *Server) page(h func(http.ResponseWriter, *http.Request, *domain.User)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
u := s.pageUser(r)
|
|
if u == nil {
|
|
http.Redirect(w, r, "/login?next="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
|
|
return
|
|
}
|
|
if u.MustChangePassword() && r.URL.Path != "/change-password" {
|
|
http.Redirect(w, r, "/change-password", http.StatusFound)
|
|
return
|
|
}
|
|
h(w, r, u)
|
|
}
|
|
}
|
|
|
|
func (s *Server) routesWeb(mux *http.ServeMux) {
|
|
staticFS, err := fs.Sub(web.FS, "static")
|
|
if err != nil {
|
|
panic(err) // embed layout is fixed at compile time
|
|
}
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/",
|
|
cacheControl(http.FileServerFS(staticFS), "public, max-age=3600")))
|
|
|
|
mux.HandleFunc("GET /{$}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
|
s.render(w, r, "home.html", &pageData{Title: "Home", User: u})
|
|
}))
|
|
|
|
mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.pageUser(r) != nil {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
s.render(w, r, "login.html", &pageData{
|
|
Title: "Log in", Narrow: true,
|
|
OIDCEnabled: s.cfg.OIDC.Enabled(),
|
|
Error: loginErrorMessage(r.URL.Query().Get("error")),
|
|
Scripts: []string{"/static/js/login.js"},
|
|
})
|
|
})
|
|
|
|
mux.HandleFunc("GET /register", func(w http.ResponseWriter, r *http.Request) {
|
|
if s.pageUser(r) != nil {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
return
|
|
}
|
|
s.render(w, r, "register.html", &pageData{
|
|
Title: "Create account", Narrow: true,
|
|
Scripts: []string{"/static/js/register.js"},
|
|
})
|
|
})
|
|
|
|
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,
|
|
Scripts: []string{"/static/js/change-password.js"},
|
|
})
|
|
}))
|
|
|
|
mux.HandleFunc("GET /profile", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
|
s.render(w, r, "profile.html", &pageData{
|
|
Title: "Profile", User: u,
|
|
HasLocalAuth: u.Auth.Local != nil,
|
|
Scripts: []string{"/static/js/profile.js"},
|
|
})
|
|
}))
|
|
|
|
mux.HandleFunc("GET /admin", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
|
if !u.Roles.Admin {
|
|
s.render(w, r, "placeholder.html", &pageData{Title: "Not authorized", User: u})
|
|
return
|
|
}
|
|
s.render(w, r, "admin.html", &pageData{
|
|
Title: "Administration", Active: "admin", User: u,
|
|
Scripts: []string{"/static/js/admin.js"},
|
|
})
|
|
}))
|
|
|
|
mux.HandleFunc("GET /consultant/board", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
|
if !u.Roles.Consultant && !u.Roles.Admin {
|
|
s.render(w, r, "placeholder.html", &pageData{Title: "Not authorized", User: u})
|
|
return
|
|
}
|
|
s.render(w, r, "atomization.html", &pageData{
|
|
Title: "Atomization Board", Active: "atomization", User: u,
|
|
Scripts: []string{"/static/js/atomization.js"},
|
|
})
|
|
}))
|
|
|
|
rolePage := func(path, tmpl, title, active, script string, check func(*domain.User) bool) {
|
|
mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
|
if check != nil && !check(u) && !u.Roles.Admin {
|
|
s.render(w, r, "placeholder.html", &pageData{Title: "Not authorized", User: u})
|
|
return
|
|
}
|
|
s.render(w, r, tmpl, &pageData{
|
|
Title: title, Active: active, User: u, Scripts: []string{script},
|
|
})
|
|
}))
|
|
}
|
|
isDev := func(u *domain.User) bool { return u.Roles.Developer }
|
|
isCons := func(u *domain.User) bool { return u.Roles.Consultant }
|
|
rolePage("/board", "board.html", "Bounty Board", "board", "/static/js/board.js", isDev)
|
|
rolePage("/my-tasks", "my-tasks.html", "My Tasks", "my-tasks", "/static/js/my-tasks.js", isDev)
|
|
rolePage("/consultant/reviews", "reviews.html", "Review Queue", "reviews", "/static/js/reviews.js", isCons)
|
|
rolePage("/consultant/pool", "pool.html", "Developer Pool", "pool", "/static/js/pool.js", isCons)
|
|
rolePage("/notifications", "notifications.html", "Notifications", "", "/static/js/notifications-page.js", nil)
|
|
|
|
mux.HandleFunc("GET /tasks/{id}", s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
|
|
s.render(w, r, "task.html", &pageData{
|
|
Title: "Task", User: u,
|
|
Scripts: []string{"/static/js/task.js"},
|
|
Data: map[string]any{"TaskID": r.PathValue("id")},
|
|
})
|
|
}))
|
|
|
|
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
|
|
rolePage("/metrics", "metrics.html", "Metrics", "metrics", "/static/js/metrics.js", nil)
|
|
}
|
|
|
|
func loginErrorMessage(code string) string {
|
|
switch code {
|
|
case "":
|
|
return ""
|
|
case "oidc_state_mismatch", "oidc_failed":
|
|
return "SSO sign-in failed. Please try again."
|
|
case "oidc_denied":
|
|
return "SSO sign-in was cancelled."
|
|
case "oidc_email_unverified":
|
|
return "Your SSO email address is not verified; ask your identity provider administrator."
|
|
case "oidc_no_email":
|
|
return "Your SSO identity has no email address; one is required."
|
|
case "account_disabled":
|
|
return "This account is disabled."
|
|
default:
|
|
return "Sign-in failed."
|
|
}
|
|
}
|
|
|
|
func cacheControl(next http.Handler, value string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", value)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|