Phase 2: device registry, dashboard cards, manual lock/unlock, audit

- Device registry: add/edit name, child and platform, persisted in devices.
- Stable per-device topic derived from name + id, unchanged by renames.
- Desired state persisted in state; timed locks expire and are audited.
- Dashboard cards: name, child, platform, status dot, state and time left.
- Manual lock/unlock forms with CSRF; every change appends to audit (FR-15).
- Append-only activity list on the dashboard. No hard deletes (rule 10).
- Store and handler tests; version bumped to 0.2.0-phase2.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 14:18:02 +11:00
parent c83c9df0a3
commit 7c88d3f52b
15 changed files with 1674 additions and 60 deletions

395
internal/web/devices.go Normal file
View File

@@ -0,0 +1,395 @@
// 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 "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)
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)
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)
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)
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
}

View File

@@ -0,0 +1,198 @@
package web
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
type testApp struct {
handler http.Handler
store *store.Store
cookies []*http.Cookie
csrf string
}
func newTestApp(t *testing.T) *testApp {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "control.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(t.Context()); err != nil {
t.Fatalf("Migrate: %v", err)
}
am, err := auth.New([]byte("0123456789abcdef0123456789abcdef"), time.Hour, false, "sam", "", "hunter2")
if err != nil {
t.Fatalf("auth.New: %v", err)
}
cfg := &config.Config{
AdminUsername: "sam",
Version: "test",
AppEnv: "test",
DBPath: ":memory:",
}
srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("web.New: %v", err)
}
rec := httptest.NewRecorder()
am.Issue(rec)
cookies := rec.Result().Cookies()
var csrf string
for _, c := range cookies {
if c.Name == "control_csrf" {
csrf = c.Value
}
}
return &testApp{handler: srv.Handler(), store: st, cookies: cookies, csrf: csrf}
}
func (a *testApp) do(t *testing.T, method, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req := httptest.NewRequest(method, path, body)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
for _, c := range a.cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func TestDashboardDeviceLifecycle(t *testing.T) {
app := newTestApp(t)
// Empty dashboard renders and offers the add form.
rec := app.do(t, http.MethodGet, "/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET / = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "No devices yet") {
t.Error("empty dashboard should show the empty state")
}
// Add a device.
rec = app.do(t, http.MethodPost, "/devices", url.Values{
"csrf": {app.csrf},
"name": {"Harry's Pixel"},
"child": {"Harry"},
"platform": {"android"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /devices = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
devs, err := app.store.ListDevices(t.Context())
if err != nil {
t.Fatalf("ListDevices: %v", err)
}
if len(devs) != 1 {
t.Fatalf("devices = %d, want 1", len(devs))
}
dev := &devs[0]
if dev.Name != "Harry's Pixel" || dev.Child != "Harry" || dev.Platform != "android" {
t.Errorf("device = %+v", dev)
}
// The card is on the dashboard.
rec = app.do(t, http.MethodGet, "/", nil)
body := rec.Body.String()
for _, want := range []string{"Harry&#39;s Pixel", "Harry", "Android", "Unlocked", "restrict/harry_s_pixel_1/config"} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
// Lock it for 30 minutes.
rec = app.do(t, http.MethodPost, "/devices/1/lock", url.Values{
"csrf": {app.csrf}, "minutes": {"30"},
})
if rec.Code != http.StatusSeeOther {
t.Fatalf("lock = %d, want 303; body=%s", rec.Code, rec.Body.String())
}
dev, err = app.store.GetDevice(t.Context(), dev.ID)
if err != nil {
t.Fatalf("GetDevice: %v", err)
}
if !dev.Locked || dev.Until == nil {
t.Fatalf("device not locked: %+v", dev)
}
rec = app.do(t, http.MethodGet, "/", nil)
body = rec.Body.String()
if !strings.Contains(body, "Locked") || !strings.Contains(body, "min left") {
t.Error("dashboard should show the locked state and time left")
}
// Edit page loads with the current values.
rec = app.do(t, http.MethodGet, "/devices/1/edit", nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Harry&#39;s Pixel") {
t.Errorf("edit page = %d", rec.Code)
}
// Unlock.
rec = app.do(t, http.MethodPost, "/devices/1/unlock", url.Values{"csrf": {app.csrf}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("unlock = %d, want 303", rec.Code)
}
dev, err = app.store.GetDevice(t.Context(), dev.ID)
if err != nil {
t.Fatalf("GetDevice: %v", err)
}
if dev.Locked || dev.Until != nil {
t.Errorf("device still locked: %+v", dev)
}
// Missing CSRF is rejected.
rec = app.do(t, http.MethodPost, "/devices/1/lock", url.Values{"minutes": {"30"}})
if rec.Code != http.StatusForbidden {
t.Errorf("lock without csrf = %d, want 403", rec.Code)
}
// Unknown device is a 404.
if rec := app.do(t, http.MethodPost, "/devices/999/lock", url.Values{"csrf": {app.csrf}}); rec.Code != http.StatusNotFound {
t.Errorf("lock unknown device = %d, want 404", rec.Code)
}
// The audit log records the whole lifecycle.
audit, err := app.store.ListAudit(t.Context(), 10)
if err != nil {
t.Fatalf("ListAudit: %v", err)
}
if len(audit) != 3 {
t.Fatalf("audit entries = %d, want 3 (%+v)", len(audit), audit)
}
}
func TestUnauthenticatedRedirectsToLogin(t *testing.T) {
app := newTestApp(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
app.handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("GET / unauthenticated = %d, want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Errorf("redirect = %q, want /login", loc)
}
}

View File

@@ -61,6 +61,12 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /partials/status", s.requireAuth(s.handleStatusPartial))
mux.HandleFunc("GET /devices/new", s.requireAuth(s.handleDeviceNew))
mux.HandleFunc("POST /devices", s.requireAuth(s.handleDeviceCreate))
mux.HandleFunc("GET /devices/{id}/edit", s.requireAuth(s.handleDeviceEdit))
mux.HandleFunc("POST /devices/{id}", s.requireAuth(s.handleDeviceUpdate))
mux.HandleFunc("POST /devices/{id}/lock", s.requireAuth(s.handleDeviceLock))
mux.HandleFunc("POST /devices/{id}/unlock", s.requireAuth(s.handleDeviceUnlock))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
@@ -223,47 +229,72 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
type statusData struct {
DBHealthy bool
DBPath string
ServerTime string
ServerUp string
}
type dashboardData struct {
Title string
Username string
CSRF string
Version string
AppEnv string
DBPath string
DeviceCount int
DBHealthy bool
ServerTime string
ServerUp string
statusData
Title string
Username string
CSRF string
Version string
AppEnv string
Devices []deviceView
Audit []auditView
Flash string
ErrMsg string
}
var serverStarted = time.Now()
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
data := s.dashboardData(w, r)
s.render(w, r, "dashboard.html", data)
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
if n, err := s.store.ExpireLocks(ctx); err != nil {
s.log.Error("expire locks", "err", err)
} else if n > 0 {
s.log.Info("expired timed locks", "count", n)
}
devs, err := s.store.ListDevices(ctx)
if err != nil {
s.log.Error("list devices", "err", err)
}
audit, err := s.store.ListAudit(ctx, 20)
if err != nil {
s.log.Error("list audit", "err", err)
}
s.render(w, r, "dashboard.html", dashboardData{
statusData: s.buildStatusData(ctx),
Title: "Dashboard",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
Devices: buildDeviceViews(devs),
Audit: buildAuditViews(audit),
Flash: strings.TrimSpace(r.URL.Query().Get("ok")),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
})
}
func (s *Server) handleStatusPartial(w http.ResponseWriter, r *http.Request) {
data := s.dashboardData(w, r)
s.renderFragment(w, r, "status.html", data)
}
func (s *Server) dashboardData(w http.ResponseWriter, r *http.Request) dashboardData {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
s.renderFragment(w, r, "status.html", s.buildStatusData(ctx))
}
count, err := s.store.CountDevices(ctx)
return dashboardData{
Title: "Dashboard",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
DBPath: s.cfg.DBPath,
DeviceCount: count,
DBHealthy: err == nil,
ServerTime: time.Now().Format("Mon 02 Jan 2006 15:04:05"),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
func (s *Server) buildStatusData(ctx context.Context) statusData {
return statusData{
DBHealthy: s.store.Ping(ctx) == nil,
DBPath: s.cfg.DBPath,
ServerTime: time.Now().Format("Mon 02 Jan 2006 15:04:05"),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
}
}

View File

@@ -297,6 +297,173 @@ code {
.login-foot { color: rgba(255, 255, 255, 0.6); }
.app-foot__sep { opacity: 0.5; }
/* ---------- Flash messages ---------- */
.flash {
margin: 0;
padding: var(--space-sm) var(--space-md);
border-radius: var(--rounded-md);
border: 1px solid var(--hairline);
border-left-width: 3px;
font: var(--type-body-sm);
}
.flash--ok {
color: var(--sticker-green);
border-left-color: var(--sticker-green);
background: color-mix(in srgb, var(--sticker-green) 8%, var(--surface));
}
.flash--error {
color: var(--sticker-red);
border-left-color: var(--sticker-red);
background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface));
}
/* ---------- Device cards ---------- */
.section__actions { display: flex; align-items: center; gap: var(--space-sm); }
a.button-primary { text-decoration: none; }
a.button-primary:hover { text-decoration: none; }
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: var(--space-md);
}
.device-card {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
padding: var(--space-lg);
display: grid;
gap: var(--space-sm);
align-content: start;
}
.device-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-sm);
}
.device-card__id { min-width: 0; }
.device-card__name { font: var(--type-title); }
.device-card__meta { margin: 2px 0 0; color: var(--ink-muted); font: var(--type-body-sm); }
.device-card__state { margin: 0; color: var(--ink); font: var(--type-body-md); }
.device-card__until { margin: 0; color: var(--ink-muted); font: var(--type-caption); }
.device-card__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
margin-top: var(--space-xs);
}
.inline-form { display: flex; align-items: center; gap: var(--space-xs); margin: 0; }
.inline-form .button-primary,
.inline-form .button-utility { padding: 10px 16px; }
.select-input {
min-height: 44px;
padding: 10px 12px;
font: var(--type-body-sm);
color: var(--ink);
background: var(--surface);
border: 1px solid #dddddd;
border-radius: var(--rounded-md);
}
[data-theme="dark"] .select-input { border-color: var(--hairline); }
.select-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
}
.device-card__foot {
margin: var(--space-xs) 0 0;
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-faint);
font: var(--type-caption);
}
.device-card__presence { display: inline-flex; align-items: center; gap: 6px; }
.device-card__topic { margin: 0; color: var(--ink-faint); font: var(--type-caption); word-break: break-all; }
/* ---------- Audit list ---------- */
.audit-list {
list-style: none;
margin: 0;
padding: 0;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
overflow: hidden;
}
.audit-item {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-xs) var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-top: 1px solid var(--hairline);
font: var(--type-body-sm);
}
.audit-item:first-child { border-top: 0; }
.audit-item--empty { color: var(--ink-muted); font-style: italic; }
.audit-item__badge {
padding: 2px 8px;
border-radius: var(--rounded-full);
background: var(--surface-2);
color: var(--ink-muted);
font: var(--type-eyebrow);
white-space: nowrap;
}
.audit-item__body { color: var(--ink-secondary); }
.audit-item__detail { color: var(--ink-muted); }
.audit-item__meta { margin-left: auto; color: var(--ink-faint); font: var(--type-caption); white-space: nowrap; }
/* ---------- Forms / narrow pages ---------- */
.container--narrow { max-width: 640px; }
.page-head__eyebrow { margin: 0; font: var(--type-body-sm); }
.page-head__eyebrow a { color: var(--ink-muted); }
.card-panel {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-1);
padding: var(--space-xl);
}
.stack-form { display: grid; gap: var(--space-lg); }
.fieldset, fieldset.field { border: 0; padding: 0; margin: 0; }
.radio-row { display: flex; flex-wrap: wrap; gap: var(--space-sm); margin-top: var(--space-xs); }
.radio-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-height: 44px;
padding: 8px 16px;
border: 1px solid var(--hairline);
border-radius: var(--rounded-full);
background: var(--surface);
font: var(--type-body-md);
color: var(--ink);
cursor: pointer;
}
.radio-chip:has(input:checked) {
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 20%, transparent);
}
.radio-chip input { accent-color: var(--primary); width: 18px; height: 18px; }
.form-actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
a.button-utility { text-decoration: none; }
/* ---------- Responsive ---------- */
@media (max-width: 760px) {
@@ -305,4 +472,6 @@ code {
.container { padding: var(--space-lg) var(--space-md); gap: var(--space-xl); }
.nav-bar { padding: var(--space-sm) var(--space-md); }
.nav-bar__user { display: none; }
.section__head { flex-wrap: wrap; }
.audit-item__meta { margin-left: 0; width: 100%; }
}

View File

@@ -27,21 +27,39 @@
<main class="container">
<header class="page-head">
<h1 class="heading-1">Dashboard</h1>
<p class="page-head__sub">Phase 1 skeleton — the Control Service is up and answering.</p>
<p class="page-head__sub">Lock or unlock a device. State is saved in SQLite and written to the audit log.</p>
</header>
{{if .Flash}}<p class="flash flash--ok" role="status">{{.Flash}}</p>{{end}}
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
{{template "status.html" .}}
<section class="section" aria-labelledby="devices-title">
<div class="section__head">
<h2 id="devices-title" class="heading-2">Devices</h2>
<span class="category-pill"><span class="dot dot--faint"></span>0 registered</span>
<div class="section__actions">
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Devices}} registered</span>
<a class="button-primary" href="/devices/new">Add device</a>
</div>
</div>
<div class="empty-state">
<p class="empty-state__title">No devices yet</p>
<p class="empty-state__body">Device registration arrives in Phase 2. Until then the
dashboard, session login and <code>/health</code> are the working pieces.</p>
{{if .Devices}}
{{template "device-cards.html" .}}
{{else}}
<div class="empty-state">
<p class="empty-state__title">No devices yet</p>
<p class="empty-state__body">Add Harry's phone or Finn's computer to give it a name,
a child, and a platform. Nothing is enforced until a later phase.</p>
</div>
{{end}}
</section>
<section class="section" aria-labelledby="audit-title">
<div class="section__head">
<h2 id="audit-title" class="heading-2">Activity</h2>
<span class="category-pill">append-only audit</span>
</div>
{{template "audit-list.html" .}}
</section>
</main>

View File

@@ -0,0 +1,80 @@
{{define "device-form.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="app">
<nav class="nav-bar">
<a class="nav-bar__brand" href="/">
<span class="nav-bar__mark" aria-hidden="true"></span>
Internet Use Control
</a>
<div class="nav-bar__right">
<span class="nav-bar__user">{{.Username}}</span>
</div>
</nav>
<main class="container container--narrow">
<header class="page-head">
<p class="page-head__eyebrow"><a href="/">← Dashboard</a></p>
<h1 class="heading-1">{{.Heading}}</h1>
<p class="page-head__sub">{{.Sub}}</p>
</header>
{{if .Error}}<p class="flash flash--error" role="alert">{{.Error}}</p>{{end}}
<section class="card-panel">
<form method="post" action="{{.FormAction}}" class="stack-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label class="field">
<span class="field__label">Device name</span>
<input class="text-input" type="text" name="name" value="{{.Name}}"
placeholder="Harry's Pixel" maxlength="80" required
autocapitalize="words" spellcheck="false">
</label>
<label class="field">
<span class="field__label">Child</span>
<input class="text-input" type="text" name="child" value="{{.Child}}"
list="children" placeholder="Harry" maxlength="40" required autocapitalize="words">
<datalist id="children">
<option value="Harry"></option>
<option value="Finn"></option>
</datalist>
</label>
<fieldset class="field">
<legend class="field__label">Platform</legend>
<div class="radio-row">
<label class="radio-chip">
<input type="radio" name="platform" value="android" {{if eq .Platform "android"}}checked{{end}}>
<span>Android</span>
</label>
<label class="radio-chip">
<input type="radio" name="platform" value="windows" {{if eq .Platform "windows"}}checked{{end}}>
<span>Windows</span>
</label>
</div>
</fieldset>
<div class="form-actions">
<button class="button-primary" type="submit">Save device</button>
<a class="button-utility" href="/">Cancel</a>
</div>
</form>
</section>
</main>
<footer class="app-foot">
<span>Control Service · {{.Version}} · {{.AppEnv}}</span>
<span class="app-foot__sep">·</span>
<span>{{year}} Family Home Lab</span>
</footer>
</body>
</html>{{end}}

View File

@@ -0,0 +1,14 @@
{{define "audit-list.html"}}<ul class="audit-list">
{{range .Audit}}
<li class="audit-item">
<span class="audit-item__badge">{{.ActionLabel}}</span>
<span class="audit-item__body">
{{if .DeviceName}}<strong>{{.DeviceName}}</strong>{{end}}
{{if .Detail}}<span class="audit-item__detail">{{.Detail}}</span>{{end}}
</span>
<span class="audit-item__meta">{{.Actor}} · {{.CreatedAtLocal}}</span>
</li>
{{else}}
<li class="audit-item audit-item--empty">No activity yet.</li>
{{end}}
</ul>{{end}}

View File

@@ -0,0 +1,49 @@
{{define "device-cards.html"}}<div class="device-grid">
{{range .Devices}}
<article class="device-card">
<header class="device-card__head">
<div class="device-card__id">
<h3 class="device-card__name">{{.Name}}</h3>
<p class="device-card__meta">{{.Child}} · {{.PlatformLabel}}</p>
</div>
<span class="status-pill {{if .Locked}}status-pill--off{{else}}status-pill--on{{end}}">
<span class="dot {{if .Locked}}dot--off{{else}}dot--on{{end}}"></span>{{.StateLabel}}
</span>
</header>
<p class="device-card__state">
{{if .Locked}}Blocked{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}}
</p>
{{if .Until}}<p class="device-card__until">Lifts {{.UntilLocal}}</p>{{end}}
<div class="device-card__actions">
<form method="post" action="/devices/{{.ID}}/lock" class="inline-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<select class="select-input" name="minutes" aria-label="Lock duration">
<option value="0">until I unlock</option>
<option value="15">15 min</option>
<option value="30" selected>30 min</option>
<option value="60">1 hour</option>
</select>
<button class="button-primary" type="submit">{{if .Locked}}Re-lock{{else}}Lock{{end}}</button>
</form>
{{if .Locked}}
<form method="post" action="/devices/{{.ID}}/unlock" class="inline-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility" type="submit">Unlock</button>
</form>
{{end}}
<a class="button-utility" href="/devices/{{.ID}}/edit">Edit</a>
</div>
<p class="device-card__foot">
<span class="device-card__presence">
<span class="dot {{if .Online}}dot--on{{else}}dot--faint{{end}}"></span>
{{if .Online}}online{{else}}offline{{end}}
</span>
{{if .HasLastSeen}}<span>· last seen {{.LastSeen}}</span>{{else}}<span>· never connected</span>{{end}}
</p>
<p class="device-card__topic"><code>{{.Topic}}</code></p>
</article>
{{end}}
</div>{{end}}