From cdf01e09d85cf7b4288862c479e61e5ce7903f3f Mon Sep 17 00:00:00 2001 From: Brittany Ellich Date: Fri, 15 May 2026 17:58:04 +0000 Subject: [PATCH] feat(admin): step 6 — admin console (dashboard, users, events, badges) - port internal/admincrypto (ed25519 Signer + Sign/Verify, key resolution via ADMIN_SIGNING_KEY env or data/admin_signing.key file) - port internal/badge (Design + canonical signing payload + RenderSVG with shield/circle/star/hexagon shapes) - features/admin/handlers.go: AdminDashboard, AdminUsers, AdminUsersPromote, AdminEvents, AdminEventNew, AdminEventCreate, AdminEventBadge, AdminEventBadgeSave; RequireAdmin middleware - features/admin/pages/{dashboard,users,events}.templ split from the old single admin_events.templ — same window-chrome + nav shell - main.go loads the signer (allowGenerate=true only in dev) and threads it through router.SetupRoutes -> admin.SetupRoutes routes (all behind RequireAdmin → 303 /signin?next=… for anon): - GET /admin dashboard - GET /admin/users (?q= ?admins= ?page=) user list - POST /admin/users/{did}/promote toggle admin - GET /admin/events event list - GET /admin/events/new new-event form - POST /admin/events create + redirect to badge - GET /admin/events/{token}/badge badge designer - POST /admin/events/{token}/badge save signed badge design verified: all 9 unauthed routes 303 to /signin with correct next=path. build clean. all tests pass. --- cmd/web/main.go | 12 +++++++++++- features/admin/handlers.go | 453 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ features/admin/pages/dashboard.templ | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ features/admin/pages/dashboard_templ.go | 452 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ features/admin/pages/events.templ | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ features/admin/pages/events_templ.go | 835 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ features/admin/pages/users.templ | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ features/admin/pages/users_templ.go | 413 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ features/admin/routes.go | 36 ++++++++++++++++++++++++++++++++++++ internal/admincrypto/signer.go | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/badge/badge.go | 215 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/badge/render.go | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ router/router.go | 4 ++++ 13 file(s) changed, 3275 insertion(s)(+), 1 deletion(s)(-) diff --git a/cmd/web/main.go b/cmd/web/main.go --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -2,6 +2,7 @@ package main import ( "atmoquest/config" + "atmoquest/internal/admincrypto" "atmoquest/internal/db" "atmoquest/internal/oauthclient" "atmoquest/internal/oauthstore" @@ -71,6 +72,15 @@ return fmt.Errorf("build oauth client: %w", err) } slog.Info("oauth client ready", "client_id", oauthClientID, "localhost", config.Global.IsLocalhost()) + // Admin signing key — used to sign event badge designs. In dev we let the + // loader generate one and write it to data/admin_signing.key; in prod + // the operator must set ADMIN_SIGNING_KEY (base64 ed25519 seed). + signer, err := admincrypto.Load(config.Global.Environment == config.Dev) + if err != nil { + return fmt.Errorf("load admin signer: %w", err) + } + slog.Info("admin signer ready", "key_id", signer.KeyID) + ns, err := nats.SetupNATS(ctx) if err != nil { return err @@ -78,7 +88,7 @@ } eg, egctx := errgroup.WithContext(ctx) - if err := router.SetupRoutes(egctx, r, sessionMgr, oauthApp, ns, conn); err != nil { + if err := router.SetupRoutes(egctx, r, sessionMgr, oauthApp, ns, conn, signer); err != nil { return fmt.Errorf("error setting up routes: %w", err) } diff --git a/features/admin/handlers.go b/features/admin/handlers.go new file mode 100644 --- /dev/null +++ b/features/admin/handlers.go @@ -0,0 +1,453 @@ +// Package admin implements the admin console — dashboard, user management, +// event CRUD, and the badge designer. Every route is mounted behind +// RequireAdmin so anonymous users get bounced to /signin and logged-in +// non-admins get a flat 403. +package admin + +import ( + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/go-chi/chi/v5" + + "atmoquest/config" + "atmoquest/features/admin/pages" + "atmoquest/features/auth" + "atmoquest/internal/admincrypto" + "atmoquest/internal/badge" + "atmoquest/internal/event" + "atmoquest/internal/users" +) + +// Handlers holds the dependencies the admin feature needs. +type Handlers struct { + DB *sql.DB + Auth *auth.Handlers + Signer *admincrypto.Signer +} + +// NewHandlers wires the admin feature. +func NewHandlers(conn *sql.DB, authH *auth.Handlers, signer *admincrypto.Signer) *Handlers { + return &Handlers{DB: conn, Auth: authH, Signer: signer} +} + +// RequireAdmin wraps a handler so it only runs when the session cookie +// identifies an active admin (users.is_admin = 1 AND is_banned = 0). +// +// - No cookie → redirect to /signin?next=. +// - Cookie present, not admin → 403 with a small page. +// - Cookie present, admin → pass through. +func (h *Handlers) RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + didStr, sid := h.Auth.Sessions.Get(r) + if didStr == "" || sid == "" { + http.Redirect(w, r, "/signin?next="+r.URL.Path, http.StatusSeeOther) + return + } + did, err := syntax.ParseDID(didStr) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusSeeOther) + return + } + ok, err := users.IsAdmin(r.Context(), h.DB, did) + if err != nil { + slog.Error("RequireAdmin: IsAdmin", "did", did.String(), "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if !ok { + // Don't leak whether the page exists — render a generic + // not-authorized page rather than 404 vs 403 distinction. + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("not authorized")) + return + } + next.ServeHTTP(w, r) + }) +} + +// AdminDashboard renders the top-level admin landing page: a small set of +// "is this app being used" stats plus quick links to the deeper admin +// sections. +func (h *Handlers) AdminDashboard(w http.ResponseWriter, r *http.Request) { + stats, err := users.Counts(r.Context(), h.DB) + if err != nil { + slog.Error("admin dashboard: counts", "err", err) + } + + // Event count is its own query — kept out of the users package so each + // package stays focused. + var eventCount, checkinCount int + _ = h.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM events`).Scan(&eventCount) + _ = h.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM checkins`).Scan(&checkinCount) + + view := pages.AdminDashboardView{ + TotalUsers: stats.TotalUsers, + AdminCount: stats.AdminCount, + BannedCount: stats.BannedCount, + ActiveLast7d: stats.ActiveLast7d, + EventCount: eventCount, + CheckinCount: checkinCount, + SignerKeyID: signerKeyID(h.Signer), + SignerPubKey: signerPubKey(h.Signer), + CurrentHandle: h.currentAdminHandle(r), + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.AdminDashboard(view).Render(r.Context(), w); err != nil { + slog.Error("render admin dashboard", "err", err) + } +} + +// AdminUsers renders the user list with optional search + admin filter. +// +// Query params: +// +// ?q=... substring search over handle/display_name/DID +// ?admins=1 show only admins +// ?page=N 1-indexed pagination (50 per page) +func (h *Handlers) AdminUsers(w http.ResponseWriter, r *http.Request) { + const perPage = 50 + q := r.URL.Query().Get("q") + adminsOnly := r.URL.Query().Get("admins") == "1" + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page < 1 { + page = 1 + } + + list, err := users.List(r.Context(), h.DB, users.ListOptions{ + Query: q, + AdminsOnly: adminsOnly, + Limit: perPage, + Offset: (page - 1) * perPage, + }) + if err != nil { + slog.Error("admin users: list", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + view := pages.AdminUsersView{ + Query: q, + AdminsOnly: adminsOnly, + Page: page, + Users: toAdminUsersRows(list), + // HasNext is a cheap signal: if we got a full page, there might be + // another. Exact count would require a second query — not worth it + // for a small admin list. + HasNext: len(list) == perPage, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.AdminUsers(view).Render(r.Context(), w); err != nil { + slog.Error("render admin users", "err", err) + } +} + +// AdminUsersPromote handles POST /admin/users/{did}/promote — toggles the +// is_admin flag based on the `make_admin` form value ("1" → admin, anything +// else → demote). +// +// The current admin can demote themselves; this is intentional so a +// sole-admin reset path exists (re-promote via the seed script). +func (h *Handlers) AdminUsersPromote(w http.ResponseWriter, r *http.Request) { + didStr := chi.URLParam(r, "did") + did, err := syntax.ParseDID(didStr) + if err != nil { + http.Error(w, "invalid DID", http.StatusBadRequest) + return + } + makeAdmin := r.FormValue("make_admin") == "1" + if err := users.SetAdmin(r.Context(), h.DB, did, makeAdmin); err != nil { + if errors.Is(err, users.ErrNotFound) { + http.Error(w, "user not found (have they signed in?)", http.StatusNotFound) + return + } + slog.Error("admin promote", "did", did.String(), "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + back := "/admin/users" + if r.URL.RawQuery != "" { + back += "?" + r.URL.RawQuery + } + http.Redirect(w, r, back, http.StatusSeeOther) +} + +// AdminEvents renders the list of admin-managed events plus the "create +// new" CTA. +func (h *Handlers) AdminEvents(w http.ResponseWriter, r *http.Request) { + list, err := event.ListAdmin(r.Context(), h.DB, 0) + if err != nil { + slog.Error("admin events: list", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + view := pages.AdminEventsView{ + PublicURL: config.Global.PublicURL, + Events: toAdminEventRows(list, config.Global.PublicURL), + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.AdminEvents(view).Render(r.Context(), w); err != nil { + slog.Error("render admin events", "err", err) + } +} + +// AdminEventNew renders the empty new-event form. +func (h *Handlers) AdminEventNew(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.AdminEventNew(pages.AdminEventFormView{}).Render(r.Context(), w); err != nil { + slog.Error("render admin event new", "err", err) + } +} + +// AdminEventCreate handles POST /admin/events — validates the form, writes a +// quest.atmo.event record to the admin's PDS, and caches the resulting event +// locally with its QR token. +func (h *Handlers) AdminEventCreate(w http.ResponseWriter, r *http.Request) { + _, viewerSess, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + in, formView, perr := parseEventForm(r) + if perr != "" { + formView.Error = perr + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _ = pages.AdminEventNew(formView).Render(r.Context(), w) + return + } + + _, qr, err := event.Put(r.Context(), viewerSess, h.DB, in) + if err != nil { + slog.Error("admin event create", "err", err) + formView.Error = "failed to create event: " + err.Error() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + _ = pages.AdminEventNew(formView).Render(r.Context(), w) + return + } + // Land the admin on the badge designer next — most events want a badge + // and this keeps the flow obvious. + http.Redirect(w, r, "/admin/events/"+qr+"/badge", http.StatusSeeOther) +} + +// AdminEventBadge renders the badge designer for an event, pre-loaded with +// any existing design. +func (h *Handlers) AdminEventBadge(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + http.NotFound(w, r) + return + } + + view := pages.AdminEventBadgeView{ + EventURI: ev.URI, + EventName: ev.Name, + Token: token, + Shapes: badge.DefaultShapes, + Primary: badge.DefaultPrimaryColors, + Accent: badge.DefaultAccentColors, + Ribbon: badge.DefaultRibbonColors, + Design: pages.AdminBadgeDesign{ + Shape: "shield", + PrimaryColor: badge.DefaultPrimaryColors[0], + AccentColor: badge.DefaultAccentColors[0], + RibbonColor: badge.DefaultRibbonColors[0], + Label: ev.Name, + }, + QRPublicURL: strings.TrimRight(config.Global.PublicURL, "/") + "/e/" + token, + } + + if h.Signer != nil { + if d, err := badge.Get(r.Context(), h.DB, h.Signer, ev.URI); err == nil { + view.Design = pages.AdminBadgeDesign{ + Shape: d.Shape, + PrimaryColor: d.PrimaryColor, + AccentColor: d.AccentColor, + RibbonColor: d.RibbonColor, + Label: d.Label, + Signature: d.Signature, + SigningKeyID: d.SigningKeyID, + } + view.SVGPreview = badge.RenderSVG(d, 200) + } else if !errors.Is(err, badge.ErrNotFound) { + slog.Warn("admin badge: get", "event_uri", ev.URI, "err", err) + } + } + + // If no preview yet, render the default-design preview so the page + // always shows something. + if view.SVGPreview == "" { + view.SVGPreview = badge.RenderSVG(badge.Design{ + Shape: view.Design.Shape, + PrimaryColor: view.Design.PrimaryColor, + AccentColor: view.Design.AccentColor, + RibbonColor: view.Design.RibbonColor, + Label: view.Design.Label, + }, 200) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.AdminEventBadge(view).Render(r.Context(), w); err != nil { + slog.Error("render admin badge", "err", err) + } +} + +// AdminEventBadgeSave handles POST /admin/events/{token}/badge. +func (h *Handlers) AdminEventBadgeSave(w http.ResponseWriter, r *http.Request) { + if h.Signer == nil { + http.Error(w, "admin signing key not configured on the server", http.StatusServiceUnavailable) + return + } + token := chi.URLParam(r, "token") + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + http.NotFound(w, r) + return + } + + d := badge.Design{ + EventURI: ev.URI, + Shape: r.FormValue("shape"), + PrimaryColor: r.FormValue("primary_color"), + AccentColor: r.FormValue("accent_color"), + RibbonColor: r.FormValue("ribbon_color"), + Label: strings.TrimSpace(r.FormValue("label")), + } + if _, err := badge.Save(r.Context(), h.DB, h.Signer, d); err != nil { + slog.Warn("admin badge save", "err", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, "/admin/events/"+token+"/badge?saved=1", http.StatusSeeOther) +} + +// parseEventForm pulls form values into a CreateInput, returning a +// pre-populated formView for re-rendering on error. +func parseEventForm(r *http.Request) (event.CreateInput, pages.AdminEventFormView, string) { + in := event.CreateInput{ + Name: strings.TrimSpace(r.FormValue("name")), + Location: strings.TrimSpace(r.FormValue("location")), + } + if n, err := strconv.Atoi(r.FormValue("expected_attendees")); err == nil { + in.ExpectedAttendees = n + } + // HTML datetime-local inputs come through as "2006-01-02T15:04" in the + // browser's local TZ. We parse as local time and convert to UTC in + // event.Put. Callers running the server in a TZ other than the event's + // local TZ will see times slightly off — for v1, that's fine; the + // conference admins running this will be co-located. + startStr := r.FormValue("start_time") + endStr := r.FormValue("end_time") + startT, errS := time.ParseInLocation("2006-01-02T15:04", startStr, time.Local) + endT, errE := time.ParseInLocation("2006-01-02T15:04", endStr, time.Local) + + view := pages.AdminEventFormView{ + Name: in.Name, + Location: in.Location, + StartTime: startStr, + EndTime: endStr, + ExpectedAttendees: in.ExpectedAttendees, + } + + if in.Name == "" { + return in, view, "event name is required" + } + if errS != nil || errE != nil { + return in, view, "start and end times are required (use the date/time pickers)" + } + if endT.Before(startT) { + return in, view, "end time must be after start time" + } + in.StartTime = startT + in.EndTime = endT + return in, view, "" +} + +// toAdminEventRows projects []event.AdminEvent into the view type. We +// pre-format times here so the template stays dumb. +func toAdminEventRows(list []event.AdminEvent, publicURL string) []pages.AdminEventRow { + publicURL = strings.TrimRight(publicURL, "/") + out := make([]pages.AdminEventRow, 0, len(list)) + for _, ev := range list { + out = append(out, pages.AdminEventRow{ + URI: ev.URI, + Name: ev.Name, + Location: ev.Location, + StartTime: ev.StartTime.Format("Mon Jan 2 · 3:04 PM"), + EndTime: ev.EndTime.Format("Mon Jan 2 · 3:04 PM"), + ExpectedAttendees: ev.ExpectedAttendees, + QRToken: ev.QRToken, + QRPublicURL: publicURL + "/e/" + ev.QRToken, + }) + } + return out +} + +// toAdminUsersRows projects users.User into the view-layer row type. We do +// this in the handler so the template stays decoupled from the storage +// struct (which has a syntax.DID, not a string). +func toAdminUsersRows(list []users.User) []pages.AdminUserRow { + out := make([]pages.AdminUserRow, 0, len(list)) + for _, u := range list { + out = append(out, pages.AdminUserRow{ + DID: u.DID.String(), + Handle: u.Handle, + DisplayName: u.DisplayName, + AuthCount: u.AuthCount, + FirstSeen: u.FirstSeenAt.Format("2006-01-02"), + LastSeen: u.LastSeenAt.Format("2006-01-02 15:04"), + IsAdmin: u.IsAdmin, + IsBanned: u.IsBanned, + }) + } + return out +} + +// currentAdminHandle returns a short label for whichever admin is viewing +// the dashboard. Used purely for cosmetics ("hi, @you"). +func (h *Handlers) currentAdminHandle(r *http.Request) string { + didStr, _ := h.Auth.Sessions.Get(r) + if didStr == "" { + return "" + } + if did, err := syntax.ParseDID(didStr); err == nil { + if u, err := users.Get(r.Context(), h.DB, did); err == nil { + if u.Handle != "" { + return u.Handle + } + } + } + return didStr +} + +// signerKeyID / signerPubKey are small nil-safe helpers so callers can +// render the signer fingerprint without nil-checking everywhere. +func signerKeyID(s *admincrypto.Signer) string { + if s == nil { + return "" + } + return s.KeyID +} + +func signerPubKey(s *admincrypto.Signer) string { + if s == nil { + return "" + } + return s.PublicKeyBase64() +} + +// JSONResponse is a tiny helper for any future JSON admin endpoints. +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +var _ = writeJSON // keep available for upcoming admin JSON endpoints diff --git a/features/admin/pages/dashboard.templ b/features/admin/pages/dashboard.templ new file mode 100644 --- /dev/null +++ b/features/admin/pages/dashboard.templ @@ -0,0 +1,136 @@ +package pages + +import "atmoquest/features/common/layouts" + +// AdminDashboardView feeds the admin landing page. +type AdminDashboardView struct { + TotalUsers int + AdminCount int + BannedCount int + ActiveLast7d int + EventCount int + CheckinCount int + // SignerKeyID + SignerPubKey are shown on the admin dashboard so the + // admin can verify which signing key the server is using (useful + // for key rotation later). Empty when no signer is configured. + SignerKeyID string + SignerPubKey string + CurrentHandle string +} + +templ AdminDashboard(v AdminDashboardView) { + @layouts.Base("admin — atmo.quest", "atmo.quest admin console.") { + @adminShell("dashboard", v.CurrentHandle) { +
+ @statTile("users", intStr(v.TotalUsers), "total signed in") + @statTile("admins", intStr(v.AdminCount), "elevated") + @statTile("active 7d", intStr(v.ActiveLast7d), "last week") + @statTile("events", intStr(v.EventCount), "all-time") + @statTile("checkins", intStr(v.CheckinCount), "all-time") + @statTile("banned", intStr(v.BannedCount), "users") +
+ + if v.SignerPubKey != "" { + + } + } + } +} + +// statTile is a tiny stat box. +templ statTile(label, value, sub string) { +
+
{ label }
+
{ value }
+
{ sub }
+
+} + +// adminShell wraps every admin page with a consistent window chrome + nav. +// The `active` param highlights the current section. +templ adminShell(active, currentHandle string) { +
+
+
+
+
+
~/atmo.quest — admin
+
+
+
+ admin{ "@" }atmo.quest:~$ + manage --section { active } +
+ + + + { children... } +
+
+
+ ● admin console · signed in as + if currentHandle != "" { + { "@" + currentHandle } + } else { + (no handle) + } +
+
v0.2
+
+
+
+ atmo.quest admin · everything here is logged +
+} + +templ adminNavLink(href, label string, active bool) { + if active { + ▸ { label } + } else { + { label } + } +} + +// intStr is a tiny inline itoa to avoid pulling strconv into the template +// package. +func intStr(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + neg := n < 0 + if neg { + n = -n + } + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// shortDID renders the last 8 chars of a DID for compact UI rows. +func shortDID(did string) string { + if len(did) <= 12 { + return did + } + return did[:8] + "…" + did[len(did)-6:] +} diff --git a/features/admin/pages/dashboard_templ.go b/features/admin/pages/dashboard_templ.go new file mode 100644 --- /dev/null +++ b/features/admin/pages/dashboard_templ.go @@ -0,0 +1,452 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "atmoquest/features/common/layouts" + +// AdminDashboardView feeds the admin landing page. +type AdminDashboardView struct { + TotalUsers int + AdminCount int + BannedCount int + ActiveLast7d int + EventCount int + CheckinCount int + // SignerKeyID + SignerPubKey are shown on the admin dashboard so the + // admin can verify which signing key the server is using (useful + // for key rotation later). Empty when no signer is configured. + SignerKeyID string + SignerPubKey string + CurrentHandle string +} + +func AdminDashboard(v AdminDashboardView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statTile("users", intStr(v.TotalUsers), "total signed in").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statTile("admins", intStr(v.AdminCount), "elevated").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statTile("active 7d", intStr(v.ActiveLast7d), "last week").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statTile("events", intStr(v.EventCount), "all-time").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statTile("checkins", intStr(v.CheckinCount), "all-time").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = statTile("banned", intStr(v.BannedCount), "users").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.SignerPubKey != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = adminShell("dashboard", v.CurrentHandle).Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("admin — atmo.quest", "atmo.quest admin console.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// statTile is a tiny stat box. +func statTile(label, value, sub string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 50, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(value) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 51, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(sub) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 52, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// adminShell wraps every admin page with a consistent window chrome + nav. +// The `active` param highlights the current section. +func adminShell(active, currentHandle string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
~/atmo.quest — admin
admin") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("@") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 68, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "atmo.quest:~$ manage --section ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(active) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 69, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var10.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
● admin console · signed in as ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if currentHandle != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs("@" + currentHandle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 85, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "(no handle)") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
v0.2
atmo.quest admin · everything here is logged
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func adminNavLink(href, label string, active bool) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + if active { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "▸ ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 100, Col: 78} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/dashboard.templ`, Line: 102, Col: 64} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) +} + +// intStr is a tiny inline itoa to avoid pulling strconv into the template +// package. +func intStr(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + neg := n < 0 + if neg { + n = -n + } + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// shortDID renders the last 8 chars of a DID for compact UI rows. +func shortDID(did string) string { + if len(did) <= 12 { + return did + } + return did[:8] + "…" + did[len(did)-6:] +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/admin/pages/events.templ b/features/admin/pages/events.templ new file mode 100644 --- /dev/null +++ b/features/admin/pages/events.templ @@ -0,0 +1,228 @@ +package pages + +import "atmoquest/features/common/layouts" + +// AdminEventsView is the data for /admin/events. +type AdminEventsView struct { + PublicURL string + Events []AdminEventRow +} + +// AdminEventRow is a single row in the admin event list. +type AdminEventRow struct { + URI string + Name string + Location string + StartTime string + EndTime string + ExpectedAttendees int + QRToken string + QRPublicURL string +} + +templ AdminEvents(v AdminEventsView) { + @layouts.Base("admin · events — atmo.quest", "Manage atmo.quest events.") { + @adminShell("events", "") { + + + if len(v.Events) == 0 { +

no events yet. create the first one.

+ } else { +
+ + + + + + + + + + + + + for _, ev := range v.Events { + + + + + + + + + } + +
namelocationstart → endexpectedQR url
{ ev.Name } + if ev.Location != "" { + { ev.Location } + } else { + — + } + { ev.StartTime }  →  { ev.EndTime }{ intStr(ev.ExpectedAttendees) }{ ev.QRPublicURL } + badge → +
+
+ } + } + } +} + +// AdminEventFormView is the data for /admin/events/new (also re-used on +// validation-error re-renders). +type AdminEventFormView struct { + Name string + Location string + StartTime string // datetime-local "2006-01-02T15:04" + EndTime string + ExpectedAttendees int + Error string +} + +templ AdminEventNew(v AdminEventFormView) { + @layouts.Base("admin · new event — atmo.quest", "Create a new atmo.quest event.") { + @adminShell("events", "") { +

create event

+ + if v.Error != "" { + + } + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + cancel +
+

+ creating an event writes a quest.atmo.event record to your PDS. + you'll be taken to the badge designer next. +

+
+ } + } +} + +// AdminEventBadgeView feeds the badge designer page. +type AdminEventBadgeView struct { + EventURI string + EventName string + Token string + Shapes []string + Primary []string + Accent []string + Ribbon []string + Design AdminBadgeDesign + SVGPreview string + QRPublicURL string +} + +// AdminBadgeDesign is the current state of the badge being designed. +type AdminBadgeDesign struct { + Shape string + PrimaryColor string + AccentColor string + RibbonColor string + Label string + Signature string + SigningKeyID string +} + +templ AdminEventBadge(v AdminEventBadgeView) { + @layouts.Base("admin · badge designer — atmo.quest", "Design an event badge.") { + @adminShell("events", "") { +

badge designer — { v.EventName }

+ +
+
+ @templ.Raw(v.SVGPreview) +

+ { v.EventURI } +

+ if v.Design.Signature != "" { +

+ ● signed (key { v.Design.SigningKeyID }) +

+ } +
+ +
+
+ + +
+ @colorSwatchField("primary_color", "primary color", v.Primary, v.Design.PrimaryColor) + @colorSwatchField("accent_color", "accent color", v.Accent, v.Design.AccentColor) + @colorSwatchField("ribbon_color", "ribbon color", v.Ribbon, v.Design.RibbonColor) +
+ + +
+
+ + download QR + ← all events +
+

+ public scan URL: { v.QRPublicURL }
+ saving signs this design with the server's admin key (ed25519). + tampering with the row in the database will fail signature + verification on next read. +

+
+
+ } + } +} + +// colorSwatchField is a radio-group with visible color swatches. +templ colorSwatchField(name, label string, options []string, selected string) { +
+ +
+ for _, c := range options { + + } +
+
+} diff --git a/features/admin/pages/events_templ.go b/features/admin/pages/events_templ.go new file mode 100644 --- /dev/null +++ b/features/admin/pages/events_templ.go @@ -0,0 +1,835 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "atmoquest/features/common/layouts" + +// AdminEventsView is the data for /admin/events. +type AdminEventsView struct { + PublicURL string + Events []AdminEventRow +} + +// AdminEventRow is a single row in the admin event list. +type AdminEventRow struct { + URI string + Name string + Location string + StartTime string + EndTime string + ExpectedAttendees int + QRToken string + QRPublicURL string +} + +func AdminEvents(v AdminEventsView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(v.Events) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

no events yet. create the first one.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, ev := range v.Events { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
namelocationstart → endexpectedQR url
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 48, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ev.Location != "" { + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Location) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 51, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "—") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(ev.StartTime) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 56, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "  →  ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(ev.EndTime) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 56, Col: 79} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(intStr(ev.ExpectedAttendees)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 57, Col: 55} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(ev.QRPublicURL) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 58, Col: 85} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "badge →
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("admin · events — atmo.quest", "Manage atmo.quest events.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// AdminEventFormView is the data for /admin/events/new (also re-used on +// validation-error re-renders). +type AdminEventFormView struct { + Name string + Location string + StartTime string // datetime-local "2006-01-02T15:04" + EndTime string + ExpectedAttendees int + Error string +} + +func AdminEventNew(v AdminEventFormView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var13 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var14 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

create event

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
heads up: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 90, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
cancel

creating an event writes a quest.atmo.event record to your PDS. you'll be taken to the badge designer next.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var14), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("admin · new event — atmo.quest", "Create a new atmo.quest event.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var13), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// AdminEventBadgeView feeds the badge designer page. +type AdminEventBadgeView struct { + EventURI string + EventName string + Token string + Shapes []string + Primary []string + Accent []string + Ribbon []string + Design AdminBadgeDesign + SVGPreview string + QRPublicURL string +} + +// AdminBadgeDesign is the current state of the badge being designed. +type AdminBadgeDesign struct { + Shape string + PrimaryColor string + AccentColor string + RibbonColor string + Label string + Signature string + SigningKeyID string +} + +func AdminEventBadge(v AdminEventBadgeView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var21 := templ.GetChildren(ctx) + if templ_7745c5c3_Var21 == nil { + templ_7745c5c3_Var21 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var22 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var23 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

badge designer — ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 158, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(v.SVGPreview).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventURI) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 164, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Design.Signature != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

● signed (key ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(v.Design.SigningKeyID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 168, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, ")

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = colorSwatchField("primary_color", "primary color", v.Primary, v.Design.PrimaryColor).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = colorSwatchField("accent_color", "accent color", v.Accent, v.Design.AccentColor).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = colorSwatchField("ribbon_color", "ribbon color", v.Ribbon, v.Design.RibbonColor).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "

public scan URL: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(v.QRPublicURL) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 199, Col: 93} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
saving signs this design with the server's admin key (ed25519). tampering with the row in the database will fail signature verification on next read.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var23), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("admin · badge designer — atmo.quest", "Design an event badge.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var22), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// colorSwatchField is a radio-group with visible color swatches. +func colorSwatchField(name, label string, options []string, selected string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var36 := templ.GetChildren(ctx) + if templ_7745c5c3_Var36 == nil { + templ_7745c5c3_Var36 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, c := range options { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/admin/pages/users.templ b/features/admin/pages/users.templ new file mode 100644 --- /dev/null +++ b/features/admin/pages/users.templ @@ -0,0 +1,173 @@ +package pages + +import "atmoquest/features/common/layouts" + +// AdminUsersView is the data for /admin/users. +type AdminUsersView struct { + Query string + AdminsOnly bool + Page int + Users []AdminUserRow + HasNext bool +} + +// AdminUserRow is a single row in the admin user list. Pre-formatted strings +// so the template doesn't need any date math. +type AdminUserRow struct { + DID string + Handle string + DisplayName string + AuthCount int + FirstSeen string + LastSeen string + IsAdmin bool + IsBanned bool +} + +templ AdminUsers(v AdminUsersView) { + @layouts.Base("admin · users — atmo.quest", "Manage atmo.quest users.") { + @adminShell("users", "") { + + + if len(v.Users) == 0 { +

no users match your filter.

+ } else { +
+ + + + + + + + + + + + + for _, u := range v.Users { + @adminUserTR(u, v.Query, v.AdminsOnly, v.Page) + } + +
handledisplay nameauthslast seenrole
+
+ +
+ if v.Page > 1 { + ← prev + } + page { intStr(v.Page) } + if v.HasNext { + next → + } +
+ } + } + } +} + +templ adminUserTR(u AdminUserRow, q string, admins bool, page int) { + + + if u.Handle != "" { + { "@" + u.Handle } + } else { + { shortDID(u.DID) } + } + + + if u.DisplayName != "" { + { u.DisplayName } + } else { + — + } + + { intStr(u.AuthCount) } + { u.LastSeen } + + if u.IsAdmin { + admin + } else if u.IsBanned { + banned + } else { + user + } + + +
+ if u.IsAdmin { + + + } else { + + + } +
+ + +} + +// adminUsersURL builds the /admin/users URL preserving search state. +func adminUsersURL(q string, admins bool, page int) string { + return "/admin/users?" + adminUsersQuery(q, admins, page) +} + +func adminUsersQuery(q string, admins bool, page int) string { + out := "q=" + urlEscape(q) + if admins { + out += "&admins=1" + } + if page > 1 { + out += "&page=" + intStr(page) + } + return out +} + +// urlEscape is a minimal escaper for the query-string params we emit. We +// only care about characters that would corrupt a URL — '&', '#', '?', ' ', +// '=', '%'. Everything else passes through. Safe because the raw string is +// user-supplied search text which we then re-display as the form input +// value via templ's HTML escaping. +func urlEscape(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == ' ': + out = append(out, '+') + case c == '&' || c == '#' || c == '?' || c == '=' || c == '%' || c == '+' || c < 0x20 || c > 0x7e: + out = append(out, '%') + out = append(out, hexDigit(c>>4)) + out = append(out, hexDigit(c&0x0f)) + default: + out = append(out, c) + } + } + return string(out) +} + +func hexDigit(n byte) byte { + if n < 10 { + return '0' + n + } + return 'a' + (n - 10) +} diff --git a/features/admin/pages/users_templ.go b/features/admin/pages/users_templ.go new file mode 100644 --- /dev/null +++ b/features/admin/pages/users_templ.go @@ -0,0 +1,413 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "atmoquest/features/common/layouts" + +// AdminUsersView is the data for /admin/users. +type AdminUsersView struct { + Query string + AdminsOnly bool + Page int + Users []AdminUserRow + HasNext bool +} + +// AdminUserRow is a single row in the admin user list. Pre-formatted strings +// so the template doesn't need any date math. +type AdminUserRow struct { + DID string + Handle string + DisplayName string + AuthCount int + FirstSeen string + LastSeen string + IsAdmin bool + IsBanned bool +} + +func AdminUsers(v AdminUsersView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(v.Users) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

no users match your filter.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, u := range v.Users { + templ_7745c5c3_Err = adminUserTR(u, v.Query, v.AdminsOnly, v.Page).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
handledisplay nameauthslast seenrole
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Page > 1 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "← prev ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "page ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(intStr(v.Page)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 78, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.HasNext { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "next →") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = adminShell("users", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("admin · users — atmo.quest", "Manage atmo.quest users.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func adminUserTR(u AdminUserRow, q string, admins bool, page int) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if u.Handle != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("@" + u.Handle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 92, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(u.DID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 94, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if u.DisplayName != "" { + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(u.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 99, Col: 19} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "—") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(intStr(u.AuthCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 104, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(u.LastSeen) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/users.templ`, Line: 105, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if u.IsAdmin { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "admin") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if u.IsBanned { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "banned") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "user") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if u.IsAdmin { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// adminUsersURL builds the /admin/users URL preserving search state. +func adminUsersURL(q string, admins bool, page int) string { + return "/admin/users?" + adminUsersQuery(q, admins, page) +} + +func adminUsersQuery(q string, admins bool, page int) string { + out := "q=" + urlEscape(q) + if admins { + out += "&admins=1" + } + if page > 1 { + out += "&page=" + intStr(page) + } + return out +} + +// urlEscape is a minimal escaper for the query-string params we emit. We +// only care about characters that would corrupt a URL — '&', '#', '?', ' ', +// '=', '%'. Everything else passes through. Safe because the raw string is +// user-supplied search text which we then re-display as the form input +// value via templ's HTML escaping. +func urlEscape(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == ' ': + out = append(out, '+') + case c == '&' || c == '#' || c == '?' || c == '=' || c == '%' || c == '+' || c < 0x20 || c > 0x7e: + out = append(out, '%') + out = append(out, hexDigit(c>>4)) + out = append(out, hexDigit(c&0x0f)) + default: + out = append(out, c) + } + } + return string(out) +} + +func hexDigit(n byte) byte { + if n < 10 { + return '0' + n + } + return 'a' + (n - 10) +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/admin/routes.go b/features/admin/routes.go new file mode 100644 --- /dev/null +++ b/features/admin/routes.go @@ -0,0 +1,36 @@ +package admin + +import ( + "database/sql" + + "github.com/go-chi/chi/v5" + + "atmoquest/features/auth" + "atmoquest/internal/admincrypto" +) + +// SetupRoutes wires the admin feature's routes behind RequireAdmin. +// +// - GET /admin — dashboard stats +// - GET /admin/users — user list (?q=, ?admins=, ?page=) +// - POST /admin/users/{did}/promote — toggle is_admin +// - GET /admin/events — event list +// - GET /admin/events/new — new-event form +// - POST /admin/events — create event + redirect to badge designer +// - GET /admin/events/{token}/badge — badge designer +// - POST /admin/events/{token}/badge — save badge design (signed) +func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers, signer *admincrypto.Signer) { + h := NewHandlers(conn, authH, signer) + + router.Route("/admin", func(r chi.Router) { + r.Use(h.RequireAdmin) + r.Get("/", h.AdminDashboard) + r.Get("/users", h.AdminUsers) + r.Post("/users/{did}/promote", h.AdminUsersPromote) + r.Get("/events", h.AdminEvents) + r.Get("/events/new", h.AdminEventNew) + r.Post("/events", h.AdminEventCreate) + r.Get("/events/{token}/badge", h.AdminEventBadge) + r.Post("/events/{token}/badge", h.AdminEventBadgeSave) + }) +} diff --git a/internal/admincrypto/signer.go b/internal/admincrypto/signer.go new file mode 100644 --- /dev/null +++ b/internal/admincrypto/signer.go @@ -0,0 +1,127 @@ +// Package admincrypto loads the server's Ed25519 signing key used to +// authenticate admin-issued artifacts (currently: event badge designs). +// +// Key resolution order: +// +// 1. `ADMIN_SIGNING_KEY` env var — base64-encoded 64-byte Ed25519 seed +// (i.e. the private key from `ed25519.GenerateKey`). This is the +// production path: rotate by changing the env var. +// +// 2. A file at `data/admin_signing.key` relative to the working +// directory. Auto-generated on first boot in dev mode if the env +// var is unset. The file is gitignored. +// +// We deliberately keep the key in-memory only at runtime — there's no +// API for callers to read the raw bytes. Sign() / Verify() are the +// public surface. +package admincrypto + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "sync" +) + +// Signer holds the Ed25519 keypair and exposes Sign/Verify. +type Signer struct { + priv ed25519.PrivateKey + pub ed25519.PublicKey + // KeyID is a short label used in stored signatures so we can rotate + // keys later by tagging signatures with which key generated them. + // Always "v1" for now. + KeyID string +} + +// Load resolves an admin signing key from env or disk. If `allowGenerate` +// is true and neither source is present, a fresh keypair is generated +// and persisted to `data/admin_signing.key`. Production deployments +// should set ADMIN_SIGNING_KEY and pass allowGenerate=false. +func Load(allowGenerate bool) (*Signer, error) { + // 1. env var + if raw := os.Getenv("ADMIN_SIGNING_KEY"); raw != "" { + seed, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("admincrypto: ADMIN_SIGNING_KEY not valid base64: %w", err) + } + if len(seed) != ed25519.SeedSize { + return nil, fmt.Errorf("admincrypto: ADMIN_SIGNING_KEY wrong size; want %d bytes, got %d", ed25519.SeedSize, len(seed)) + } + priv := ed25519.NewKeyFromSeed(seed) + return &Signer{priv: priv, pub: priv.Public().(ed25519.PublicKey), KeyID: "v1"}, nil + } + + // 2. file (dev mode) + const path = "data/admin_signing.key" + if seed, err := os.ReadFile(path); err == nil { + if len(seed) != ed25519.SeedSize { + return nil, fmt.Errorf("admincrypto: %s wrong size; want %d bytes, got %d", path, ed25519.SeedSize, len(seed)) + } + priv := ed25519.NewKeyFromSeed(seed) + return &Signer{priv: priv, pub: priv.Public().(ed25519.PublicKey), KeyID: "v1"}, nil + } + + // 3. generate (dev mode opt-in) + if !allowGenerate { + return nil, errors.New("admincrypto: no ADMIN_SIGNING_KEY env var and no data/admin_signing.key file") + } + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("admincrypto: generate: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("admincrypto: mkdir: %w", err) + } + if err := os.WriteFile(path, priv.Seed(), 0o600); err != nil { + return nil, fmt.Errorf("admincrypto: write key: %w", err) + } + return &Signer{priv: priv, pub: pub, KeyID: "v1"}, nil +} + +// MustLoad is a convenience wrapper for main() that panics on failure. +func MustLoad(allowGenerate bool) *Signer { + s, err := Load(allowGenerate) + if err != nil { + panic(err) + } + return s +} + +// Sign returns a base64-encoded Ed25519 signature over msg. +func (s *Signer) Sign(msg []byte) string { + if s == nil { + return "" + } + sig := ed25519.Sign(s.priv, msg) + return base64.StdEncoding.EncodeToString(sig) +} + +// Verify returns true if encodedSig (base64) is a valid Ed25519 signature +// over msg under this signer's public key. +func (s *Signer) Verify(msg []byte, encodedSig string) bool { + if s == nil { + return false + } + sig, err := base64.StdEncoding.DecodeString(encodedSig) + if err != nil { + return false + } + return ed25519.Verify(s.pub, msg, sig) +} + +// PublicKeyBase64 returns the base64-encoded public key — useful for an +// admin debug page that wants to show what the server is verifying with. +func (s *Signer) PublicKeyBase64() string { + if s == nil { + return "" + } + return base64.StdEncoding.EncodeToString(s.pub) +} + +// guard against accidental concurrent rotation. Unused for now; reserved +// for a future RotateKey method. +var _ sync.Mutex diff --git a/internal/badge/badge.go b/internal/badge/badge.go new file mode 100644 --- /dev/null +++ b/internal/badge/badge.go @@ -0,0 +1,215 @@ +// Package badge stores and renders signed event badge designs. +// +// A badge design is the visual template an admin defines for an event: +// shape + colors + label. When an admin saves a design we compute a +// canonical JSON representation, sign it with the server's Ed25519 +// admin signing key, and persist (design + signature) together. On +// every read we re-verify the signature so any tampering with the +// underlying SQLite row is detected. +// +// Issuance (binding a badge to a specific user as a credential) is a +// separate concern and not implemented in this package — that's badge +// *award* and lives in a future package. Per the spec, v1 only signs +// the *design*. +package badge + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "atmoquest/internal/admincrypto" +) + +// Default palette options shown in the admin badge designer. Kept +// small so the UI stays a single-screen form — a wider palette is a +// future enhancement. +var ( + // DefaultShapes are the four shape options the admin can pick. + DefaultShapes = []string{"circle", "shield", "star", "hexagon"} + + // DefaultPrimaryColors / DefaultAccentColors / DefaultRibbonColors + // are Catppuccin-ish swatches chosen to read well at small sizes. + DefaultPrimaryColors = []string{"#fab387", "#f9e2af", "#a6e3a1", "#89b4fa", "#cba6f7", "#f38ba8"} + DefaultAccentColors = []string{"#11111b", "#1e1e2e", "#313244", "#cdd6f4"} + DefaultRibbonColors = []string{"#f38ba8", "#94e2d5", "#89dceb", "#fab387", "#a6e3a1"} +) + +// ErrNotFound is returned by Get when no badge exists for the event. +var ErrNotFound = errors.New("badge: not found") + +// ErrInvalidSignature is returned when a stored signature fails verification +// on read. Callers should treat this as a security incident — the row was +// tampered with after creation. +var ErrInvalidSignature = errors.New("badge: signature failed verification") + +// Design is the persisted badge configuration plus signature metadata. +type Design struct { + EventURI string + Shape string + PrimaryColor string + AccentColor string + RibbonColor string + Label string + Signature string // base64 + SigningKeyID string // typically "v1" + CreatedAt time.Time + UpdatedAt time.Time +} + +// canonicalPayload is the deterministic JSON we sign. It deliberately +// excludes timestamps (which change on update) and signature fields +// (which depend on this payload). Field order is alphabetical because +// Go's encoding/json sorts struct field encoding by the struct order, +// so we list fields alphabetically here for stability. +type canonicalPayload struct { + AccentColor string `json:"accent_color"` + EventURI string `json:"event_uri"` + Label string `json:"label"` + PrimaryColor string `json:"primary_color"` + RibbonColor string `json:"ribbon_color"` + Shape string `json:"shape"` +} + +// canonicalBytes returns the deterministic byte representation of d for +// signing / verification. Any change to the design produces a fresh +// signature. +func canonicalBytes(d Design) ([]byte, error) { + p := canonicalPayload{ + AccentColor: d.AccentColor, + EventURI: d.EventURI, + Label: d.Label, + PrimaryColor: d.PrimaryColor, + RibbonColor: d.RibbonColor, + Shape: d.Shape, + } + return json.Marshal(p) +} + +// Save upserts a badge design for the given event, signing it with the +// supplied admincrypto.Signer. Replaces any prior design for the same +// event — admins iterate freely until they're happy. +func Save(ctx context.Context, db *sql.DB, signer *admincrypto.Signer, d Design) (Design, error) { + if signer == nil { + return Design{}, errors.New("badge: nil signer") + } + if err := validate(d); err != nil { + return Design{}, err + } + + body, err := canonicalBytes(d) + if err != nil { + return Design{}, fmt.Errorf("badge: canonicalize: %w", err) + } + d.Signature = signer.Sign(body) + d.SigningKeyID = signer.KeyID + + _, err = db.ExecContext(ctx, ` + INSERT INTO event_badges ( + event_uri, shape, primary_color, accent_color, ribbon_color, + label, signature, signing_key_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(event_uri) DO UPDATE SET + shape = excluded.shape, + primary_color = excluded.primary_color, + accent_color = excluded.accent_color, + ribbon_color = excluded.ribbon_color, + label = excluded.label, + signature = excluded.signature, + signing_key_id = excluded.signing_key_id, + updated_at = CURRENT_TIMESTAMP + `, + d.EventURI, d.Shape, d.PrimaryColor, d.AccentColor, d.RibbonColor, + d.Label, d.Signature, d.SigningKeyID, + ) + if err != nil { + return Design{}, fmt.Errorf("badge: save: %w", err) + } + return d, nil +} + +// Get returns the badge design for an event AND verifies its signature. +// Returns ErrNotFound when no design is saved; ErrInvalidSignature when +// the stored signature doesn't verify under signer. +func Get(ctx context.Context, db *sql.DB, signer *admincrypto.Signer, eventURI string) (Design, error) { + row := db.QueryRowContext(ctx, ` + SELECT event_uri, shape, primary_color, accent_color, ribbon_color, + label, signature, signing_key_id, created_at, updated_at + FROM event_badges + WHERE event_uri = ? + `, eventURI) + var d Design + err := row.Scan( + &d.EventURI, &d.Shape, &d.PrimaryColor, &d.AccentColor, &d.RibbonColor, + &d.Label, &d.Signature, &d.SigningKeyID, &d.CreatedAt, &d.UpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return Design{}, ErrNotFound + } + if err != nil { + return Design{}, err + } + + if signer != nil { + body, err := canonicalBytes(d) + if err != nil { + return Design{}, err + } + if !signer.Verify(body, d.Signature) { + return Design{}, ErrInvalidSignature + } + } + return d, nil +} + +// validate enforces the small set of constraints we want at the storage +// layer; the admin form will also surface these errors at submit. +func validate(d Design) error { + if d.EventURI == "" { + return errors.New("badge: empty event URI") + } + if !contains(DefaultShapes, d.Shape) { + return fmt.Errorf("badge: shape %q not in defaults", d.Shape) + } + if !isHex(d.PrimaryColor) || !isHex(d.AccentColor) || !isHex(d.RibbonColor) { + return errors.New("badge: colors must be #RRGGBB hex") + } + if d.Label == "" { + return errors.New("badge: empty label") + } + if len(d.Label) > 40 { + return errors.New("badge: label too long (max 40 chars)") + } + return nil +} + +func contains(haystack []string, needle string) bool { + for _, h := range haystack { + if h == needle { + return true + } + } + return false +} + +// isHex matches "#RRGGBB" — a strict 7-char form. Short-form (#RGB) is +// rejected so the SVG renderer doesn't need to expand it. +func isHex(s string) bool { + if len(s) != 7 || s[0] != '#' { + return false + } + for i := 1; i < 7; i++ { + c := s[i] + switch { + case c >= '0' && c <= '9': + case c >= 'a' && c <= 'f': + case c >= 'A' && c <= 'F': + default: + return false + } + } + return true +} diff --git a/internal/badge/render.go b/internal/badge/render.go new file mode 100644 --- /dev/null +++ b/internal/badge/render.go @@ -0,0 +1,192 @@ +package badge + +import ( + "fmt" + "html" + "strings" +) + +// RenderSVG returns a self-contained SVG string rendering this badge +// design at the given pixel size. The output uses only inline +// attributes (no