// SPDX-FileCopyrightText: 2026 Echolot contributors // SPDX-License-Identifier: GPL-3.0-or-later package adminui import ( "encoding/json" "log/slog" "net/http" "net/url" "sort" "time" "echo-lot.app/server/internal/adminauth" "echo-lot.app/server/internal/runs" "echo-lot.app/server/internal/store" ) func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) { if s.session(r) != nil { http.Redirect(w, r, "/", http.StatusSeeOther) return } s.render(w, r, "login", map[string]any{ "OIDC": s.oidcAvailable(), "LocalSet": s.Store.LocalAdmin() != nil, "AdminUser": s.AdminUser, }) } func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { devices := s.Store.Devices() linked := 0 for _, d := range devices { if d.LinkedToAccount() { linked++ } } var selftest any if s.SelfTest != nil { selftest = s.SelfTest() } s.render(w, r, "dashboard", map[string]any{ "Session": sess, "CSRF": s.csrfToken(sess), "Devices": len(devices), "Linked": linked, "Runs": s.totalRuns(devices), "SelfTest": selftest, "Version": s.Version, }) } func (s *Server) totalRuns(devices []store.Device) int { if s.Runs == nil { return 0 } n := 0 for _, d := range devices { n += len(s.Runs.List(d.ID)) } return n } func (s *Server) devices(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { devices := s.Store.Devices() // Newest first: the device someone is looking for is almost always the one just enrolled. sort.Slice(devices, func(i, j int) bool { return devices[i].Enrolled.After(devices[j].Enrolled) }) type row struct { store.Device Runs int } rows := make([]row, 0, len(devices)) for _, d := range devices { n := 0 if s.Runs != nil { n = len(s.Runs.List(d.ID)) } rows = append(rows, row{Device: d, Runs: n}) } s.render(w, r, "devices", map[string]any{ "Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows, "Link": r.URL.Query().Get("link"), }) } func (s *Server) revokeDevice(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { id := r.PathValue("id") if err := s.Store.DeleteDevice(id); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Worth a log line: revoking a device is destructive, immediate, and someone will eventually // want to know who did it and when. slog.Info("device revoked", "device", id, "by", sess.Subject) http.Redirect(w, r, "/devices", http.StatusSeeOther) } func (s *Server) mintToken(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { tok, err := s.Store.NewEnrollToken(24*time.Hour, "admin-ui") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } slog.Info("enrolment token minted", "by", sess.Subject) // The whole link, not the bare token: it carries the URL and the pin as well, and assembling // those by hand is where an operator gets a pin wrong by one character. http.Redirect(w, r, "/devices?link="+url.QueryEscape(s.EnrollLink(tok)), http.StatusSeeOther) } // EnrollLink is supplied by the caller so this package does not need the control server's pin. var _ = 0 func (s *Server) runsList(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { type row struct { runs.Meta DeviceName string } var rows []row for _, d := range s.Store.Devices() { if s.Runs == nil { break } name := d.Name if name == "" { name = d.ID } for _, m := range s.Runs.List(d.ID) { rows = append(rows, row{Meta: m, DeviceName: name}) } } sort.Slice(rows, func(i, j int) bool { return rows[i].UploadedAt.After(rows[j].UploadedAt) }) if len(rows) > 200 { rows = rows[:200] // a page, not the archive; the count is on the dashboard } s.render(w, r, "runs", map[string]any{"Session": sess, "CSRF": s.csrfToken(sess), "Rows": rows}) } func (s *Server) runView(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { body, err := s.Runs.Get(r.PathValue("device"), r.PathValue("id")) if err != nil { http.NotFound(w, r) return } // Re-indented for reading, but otherwise exactly what was stored. An admin sees the document // at the privacy level its uploader chose — there is nothing here that can un-redact it. var pretty json.RawMessage = body out, err := json.MarshalIndent(json.RawMessage(pretty), "", " ") if err != nil { out = body } s.render(w, r, "run", map[string]any{ "Session": sess, "CSRF": s.csrfToken(sess), "Device": r.PathValue("device"), "ID": r.PathValue("id"), "JSON": string(out), }) } func (s *Server) runDelete(w http.ResponseWriter, r *http.Request, sess *adminauth.Session) { device, id := r.PathValue("device"), r.PathValue("id") if err := s.Runs.Delete(device, id); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } slog.Info("run deleted", "device", device, "run", id, "by", sess.Subject) http.Redirect(w, r, "/runs", http.StatusSeeOther) }