// Device registry and manual lock/unlock handlers (AGENTS.md §5 screens 2–3, // §11 Phase 2). Only the Control Service writes device state; the dashboard is // the admin surface. package web import ( "context" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) // deviceView is the display model for one dashboard card. type deviceView struct { ID int64 Name string Child string Platform string PlatformLabel string Topic string Online bool Locked bool Until *time.Time UntilLocal string TimeLeft string StateLabel string LastSeen string HasLastSeen bool } // auditView is the display model for one audit row. type auditView struct { Action string ActionLabel string Actor string DeviceName string Detail string CreatedAtLocal string } func buildDeviceViews(devs []store.Device) []deviceView { out := make([]deviceView, 0, len(devs)) for _, d := range devs { v := deviceView{ ID: d.ID, Name: d.Name, Child: d.Child, Platform: d.Platform, PlatformLabel: platformLabel(d.Platform), Topic: d.Topic, Online: d.Online, Locked: d.Locked, Until: d.Until, } if d.Locked { v.StateLabel = "Locked" } else { v.StateLabel = "Unlocked" } if d.Until != nil { local := d.Until.Local() v.UntilLocal = local.Format("Mon 15:04") v.TimeLeft = timeLeft(*d.Until) } if d.LastSeen != nil { v.LastSeen = d.LastSeen.Local().Format("Mon 02 Jan 15:04") v.HasLastSeen = true } out = append(out, v) } return out } func buildAuditViews(entries []store.AuditEntry) []auditView { out := make([]auditView, 0, len(entries)) for _, e := range entries { v := auditView{ Action: e.Action, ActionLabel: auditLabel(e.Action), Actor: e.Actor, DeviceName: e.DeviceName, Detail: e.Detail, } if !e.CreatedAt.IsZero() { v.CreatedAtLocal = e.CreatedAt.Local().Format("02 Jan 15:04:05") } out = append(out, v) } return out } func platformLabel(p string) string { switch p { case store.PlatformAndroid: return "Android" case store.PlatformWindows: return "Windows" default: return p } } // timeLeft renders how long remains on a timed lock. func timeLeft(until time.Time) string { d := time.Until(until) if d <= 0 { return "lifting now" } d = d.Round(time.Minute) if d < time.Minute { return "under a minute left" } h := int(d.Hours()) m := int(d.Minutes()) % 60 switch { case h == 0: return fmt.Sprintf("%d min left", m) case m == 0: return fmt.Sprintf("%dh left", h) default: return fmt.Sprintf("%dh %dm left", h, m) } } func auditLabel(action string) string { switch action { case "device.created": return "Device added" case "device.updated": return "Device edited" case "state.locked": return "Locked" case "state.unlocked": return "Unlocked" case "state.expired": return "Lock expired" case "device.online": return "Device online" case "device.offline": return "Device offline" case "login.success": return "Signed in" case "login.failed": return "Sign-in failed" case "login.csrf_rejected": return "Sign-in rejected" case "logout": return "Signed out" default: return action } } // ---------- dashboard data ---------- type deviceFormData struct { Title string Heading string Sub string Username string CSRF string Version string AppEnv string FormAction string Name string Child string Platform string Error string } // ---------- handlers ---------- func (s *Server) handleDeviceNew(w http.ResponseWriter, r *http.Request) { s.render(w, r, "device-form.html", deviceFormData{ Title: "Add device", Heading: "Add a device", Sub: "Name the device, assign it to a child, and choose its platform.", Username: s.cfg.AdminUsername, CSRF: s.auth.EnsureCSRF(w, r), Version: s.cfg.Version, AppEnv: s.cfg.AppEnv, FormAction: "/devices", Platform: store.PlatformAndroid, }) } func (s *Server) handleDeviceCreate(w http.ResponseWriter, r *http.Request) { if !s.requireCSRF(w, r) { return } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } name := r.PostFormValue("name") child := r.PostFormValue("child") platform := r.PostFormValue("platform") ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() dev, err := s.store.CreateDevice(ctx, name, child, platform, s.cfg.AdminUsername) if err != nil { s.log.Warn("device create rejected", "err", err, "remote", r.RemoteAddr) s.render(w, r, "device-form.html", deviceFormData{ Title: "Add device", Heading: "Add a device", Sub: "Name the device, assign it to a child, and choose its platform.", Username: s.cfg.AdminUsername, CSRF: s.auth.EnsureCSRF(w, r), Version: s.cfg.Version, AppEnv: s.cfg.AppEnv, FormAction: "/devices", Name: strings.TrimSpace(name), Child: strings.TrimSpace(child), Platform: strings.ToLower(strings.TrimSpace(platform)), Error: err.Error(), }) return } s.log.Info("device created", "id", dev.ID, "name", dev.Name, "topic", dev.Topic) s.publishConfig(ctx, *dev) http.Redirect(w, r, "/?ok="+url.QueryEscape("Added "+dev.Name), http.StatusSeeOther) } func (s *Server) handleDeviceEdit(w http.ResponseWriter, r *http.Request) { id, ok := s.deviceID(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() dev, err := s.store.GetDevice(ctx, id) if err != nil { s.deviceLookupError(w, r, err) return } s.render(w, r, "device-form.html", deviceFormData{ Title: "Edit " + dev.Name, Heading: "Edit device", Sub: fmt.Sprintf("%s · %s", platformLabel(dev.Platform), dev.Topic), Username: s.cfg.AdminUsername, CSRF: s.auth.EnsureCSRF(w, r), Version: s.cfg.Version, AppEnv: s.cfg.AppEnv, FormAction: fmt.Sprintf("/devices/%d", dev.ID), Name: dev.Name, Child: dev.Child, Platform: dev.Platform, }) } func (s *Server) handleDeviceUpdate(w http.ResponseWriter, r *http.Request) { id, ok := s.deviceID(w, r) if !ok { return } if !s.requireCSRF(w, r) { return } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } name := r.PostFormValue("name") child := r.PostFormValue("child") platform := r.PostFormValue("platform") ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() dev, err := s.store.UpdateDevice(ctx, id, name, child, platform, s.cfg.AdminUsername) if err != nil { if errors.Is(err, store.ErrNotFound) { s.deviceLookupError(w, r, err) return } s.render(w, r, "device-form.html", deviceFormData{ Title: "Edit device", Heading: "Edit device", Sub: "Correct the details and save again.", Username: s.cfg.AdminUsername, CSRF: s.auth.EnsureCSRF(w, r), Version: s.cfg.Version, AppEnv: s.cfg.AppEnv, FormAction: fmt.Sprintf("/devices/%d", id), Name: strings.TrimSpace(name), Child: strings.TrimSpace(child), Platform: strings.ToLower(strings.TrimSpace(platform)), Error: err.Error(), }) return } s.log.Info("device updated", "id", dev.ID, "name", dev.Name) s.publishConfig(ctx, *dev) http.Redirect(w, r, "/?ok="+url.QueryEscape("Saved "+dev.Name), http.StatusSeeOther) } func (s *Server) handleDeviceLock(w http.ResponseWriter, r *http.Request) { id, ok := s.deviceID(w, r) if !ok { return } if !s.requireCSRF(w, r) { return } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } minutes, _ := strconv.Atoi(strings.TrimSpace(r.PostFormValue("minutes"))) if minutes < 0 || minutes > 24*60 { minutes = 0 } var until *time.Time if minutes > 0 { t := time.Now().Add(time.Duration(minutes) * time.Minute).UTC() until = &t } ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() dev, err := s.store.SetDeviceLock(ctx, id, true, until, s.cfg.AdminUsername, "state.locked") if err != nil { s.deviceLookupError(w, r, err) return } s.log.Info("device locked", "id", dev.ID, "name", dev.Name, "minutes", minutes) s.publishConfig(ctx, *dev) msg := "Locked " + dev.Name if minutes > 0 { msg = fmt.Sprintf("Locked %s for %d min", dev.Name, minutes) } http.Redirect(w, r, "/?ok="+url.QueryEscape(msg), http.StatusSeeOther) } func (s *Server) handleDeviceUnlock(w http.ResponseWriter, r *http.Request) { id, ok := s.deviceID(w, r) if !ok { return } if !s.requireCSRF(w, r) { return } if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() dev, err := s.store.SetDeviceLock(ctx, id, false, nil, s.cfg.AdminUsername, "state.unlocked") if err != nil { s.deviceLookupError(w, r, err) return } s.log.Info("device unlocked", "id", dev.ID, "name", dev.Name) s.publishConfig(ctx, *dev) http.Redirect(w, r, "/?ok="+url.QueryEscape("Unlocked "+dev.Name), http.StatusSeeOther) } // ---------- helpers ---------- func (s *Server) deviceID(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id <= 0 { http.NotFound(w, r) return 0, false } return id, true } func (s *Server) deviceLookupError(w http.ResponseWriter, r *http.Request, err error) { if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } s.log.Error("device handler", "err", err, "path", r.URL.Path) http.Error(w, "internal server error", http.StatusInternalServerError) } func (s *Server) requireCSRF(w http.ResponseWriter, r *http.Request) bool { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return false } if !s.auth.CheckCSRF(r) { s.log.Warn("rejected: bad csrf token", "path", r.URL.Path, "remote", r.RemoteAddr) http.Error(w, "invalid CSRF token", http.StatusForbidden) return false } return true }