From 7c88d3f52b9d36b0e36697b5972d2cea7f955039 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Tue, 13 Oct 2026 14:18:02 +1100 Subject: [PATCH] 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 --- Makefile | 4 +- README.md | 39 +- cmd/control/main.go | 4 +- docker-compose.yml | 4 +- internal/store/devices.go | 434 ++++++++++++++++++ internal/store/devices_test.go | 216 +++++++++ internal/store/store.go | 9 +- internal/web/devices.go | 395 ++++++++++++++++ internal/web/devices_test.go | 198 ++++++++ internal/web/server.go | 89 ++-- internal/web/static/app.css | 169 +++++++ internal/web/templates/dashboard.html | 30 +- internal/web/templates/device-form.html | 80 ++++ .../web/templates/partials/audit-list.html | 14 + .../web/templates/partials/device-cards.html | 49 ++ 15 files changed, 1674 insertions(+), 60 deletions(-) create mode 100644 internal/store/devices.go create mode 100644 internal/store/devices_test.go create mode 100644 internal/web/devices.go create mode 100644 internal/web/devices_test.go create mode 100644 internal/web/templates/device-form.html create mode 100644 internal/web/templates/partials/audit-list.html create mode 100644 internal/web/templates/partials/device-cards.html diff --git a/Makefile b/Makefile index 4ef3f81..898f9c3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -IMAGE ?= webrestrict/control:0.1.0-phase1 -BUILD_VERSION ?= 0.1.0-phase1 +IMAGE ?= webrestrict/control:0.2.0-phase2 +BUILD_VERSION ?= 0.2.0-phase2 .PHONY: help test vet build up down restart logs ps hash-password fmt diff --git a/README.md b/README.md index 73b5d02..82a3848 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Internet Use Control project (`SYSTEM_MAP.md`). It holds schedules, blocklists, per-device state and usage, and serves the web dashboard. Only it publishes device config. -This repository currently contains **Phase 1 — Skeleton** (AGENTS.md §11): +This repository currently contains **Phase 2 — Devices and state** +(AGENTS.md §11), on top of the Phase 1 skeleton: - Go service with its own image (`Dockerfile`) and compose file. - SQLite schema for the whole data model (§7), applied through versioned @@ -16,9 +17,14 @@ This repository currently contains **Phase 1 — Skeleton** (AGENTS.md §11): - `GET /health`. - HTMX + server-rendered dashboard styled with the Family Home Lab design system (`tokens.css` copied in). +- **Device registry**: add and edit a device, assign it to a child, choose its + platform. Persisted in `devices`. +- **Dashboard cards**: name, child, platform, status, current state, time left. +- **Manual lock/unlock** from the dashboard, persisted in `state` and written + to the append-only `audit` log (FR-15). -Enforcement, MQTT, devices, schedules and the clients arrive in later phases. -Nothing here touches the router, Pi-hole, or DNS. +Enforcement, MQTT, schedules, blocklists and the clients arrive in later +phases. Nothing here touches the router, Pi-hole, or DNS. ## Quick start (on `.13`) @@ -34,7 +40,7 @@ docker compose up -d --build # 3. Prove it. curl -s http://127.0.0.1:8791/health -# {"status":"ok","service":"control-service","version":"0.1.0-phase1",...} +# {"status":"ok","service":"control-service","version":"0.2.0-phase2",...} ``` Open in a browser. It redirects to `/login`. Sign in @@ -63,11 +69,11 @@ reads only the process environment. Generate a bcrypt hash without host Go: ```bash -docker run --rm --entrypoint /app/control webrestrict/control:0.1.0-phase1 \ +docker run --rm --entrypoint /app/control webrestrict/control:0.2.0-phase2 \ hash-password 'your-password' ``` -## Endpoints (Phase 1) +## Endpoints (Phase 2) | Method | Path | Notes | |---|---|---| @@ -75,8 +81,17 @@ docker run --rm --entrypoint /app/control webrestrict/control:0.1.0-phase1 \ | `GET` | `/login` | Session login form. | | `POST` | `/login` | Verifies credentials, sets a signed session cookie. | | `POST` | `/logout` | Clears the session. | -| `GET` | `/` | Dashboard. Redirects to `/login` when not signed in. | +| `GET` | `/` | Dashboard: service status, device cards, recent activity. | | `GET` | `/partials/status` | HTMX fragment, polled every 10 s. | +| `GET` | `/devices/new` | Add-device form. | +| `POST` | `/devices` | Creates a device. | +| `GET` | `/devices/{id}/edit` | Edit-device form. | +| `POST` | `/devices/{id}` | Updates name, child, platform. | +| `POST` | `/devices/{id}/lock` | Locks now; optional `minutes` (0 = until unlocked). | +| `POST` | `/devices/{id}/unlock` | Unlocks now. | + +Every form POST needs the `csrf` field from the session cookie. State changes +append to the `audit` table. Devices are never hard-deleted (§10 rule 10). ## Development @@ -95,11 +110,12 @@ make logs # follow container logs ``` cmd/control/ service entry point (+ hash-password helper) internal/config/ environment configuration -internal/store/ SQLite open, migrations, audit +internal/store/ SQLite open, migrations, audit, devices + state internal/store/migrations/001_init.sql internal/auth/ signed-cookie session login + CSRF internal/web/ HTTP handlers, templates, static assets - templates/ login, dashboard, HTMX partials + devices.go device registry + manual lock/unlock handlers + templates/ login, dashboard, device form, HTMX partials static/ tokens.css (Family Home Lab), app.css, htmx, Inter fonts Dockerfile own image (multi-stage, CGO-free) docker-compose.yml own container, port 8791 @@ -113,9 +129,10 @@ deploy/caddy/ Caddy vhost for webrestrict.lab.audasmedia.com.au `webrestrict.lab.audasmedia.com.au`. The terminating Caddy runs on `.35` (sam-Ubuntu1); see that file for the two proxy patterns and the apply commands. -## Non-goals (Phase 1) +## Non-goals (Phase 2) - No MQTT publishing (Phase 3). -- No device registry or lock/unlock UI (Phase 2). +- No schedules, blocklists or allowlists (Phase 6). +- No telemetry or usage screens (Phase 7). - No admin API (Phase 8). - No Hub contact (Phase 9). diff --git a/cmd/control/main.go b/cmd/control/main.go index 228a5a8..6162eb4 100644 --- a/cmd/control/main.go +++ b/cmd/control/main.go @@ -1,8 +1,8 @@ // Command control is the Internet Use Control Control Service. // // It is the single source of truth for the family's device rules -// (SYSTEM_MAP.md). Phase 1: HTTP skeleton, SQLite schema, config from the -// environment, and a separate session login. +// (SYSTEM_MAP.md). Phase 2: device registry, desired state in SQLite, manual +// lock/unlock from the dashboard, and the audit log. package main import ( diff --git a/docker-compose.yml b/docker-compose.yml index 37d50c4..bdad663 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,8 @@ services: build: context: . args: - VERSION: "0.1.0-phase1" - image: webrestrict/control:0.1.0-phase1 + VERSION: "0.2.0-phase2" + image: webrestrict/control:0.2.0-phase2 container_name: webrestrict-control restart: unless-stopped env_file: diff --git a/internal/store/devices.go b/internal/store/devices.go new file mode 100644 index 0000000..e9f4e9a --- /dev/null +++ b/internal/store/devices.go @@ -0,0 +1,434 @@ +// Device registry and desired state (AGENTS.md §7 devices/state, §11 Phase 2). +// +// The Control Service is the only writer of device config (SYSTEM_MAP.md). +// Every mutation appends to the audit log (FR-15), and nothing is ever +// hard-deleted (§10 rule 10). +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// ErrNotFound is returned when a device id does not exist. +var ErrNotFound = errors.New("device not found") + +// Platform values allowed by the devices.platform CHECK constraint. +const ( + PlatformAndroid = "android" + PlatformWindows = "windows" +) + +// ValidPlatform reports whether p is one of the supported platforms. +func ValidPlatform(p string) bool { + return p == PlatformAndroid || p == PlatformWindows +} + +// Device is one registered device plus its desired state. +type Device struct { + ID int64 + Name string + Child string + Platform string + Topic string + Online bool + LastSeen *time.Time + CreatedAt time.Time + UpdatedAt time.Time + + // Desired state, from the state table. + Locked bool + Until *time.Time + StateAt time.Time +} + +// AuditEntry is one row of the append-only audit log (FR-15). +type AuditEntry struct { + ID int64 + Actor string + Action string + DeviceID *int64 + DeviceName string + Detail string + CreatedAt time.Time +} + +// selectDevice is the shared projection for reading a device with its state. +const selectDevice = ` +SELECT d.id, d.name, d.child, d.platform, d.topic, d.online, d.last_seen, + d.created_at, d.updated_at, + COALESCE(s.locked, 0), s.until, s.updated_at +FROM devices d +LEFT JOIN state s ON s.device_id = d.id` + +func scanDevice(row interface { + Scan(dest ...any) error +}) (*Device, error) { + var ( + dev Device + online int + lastSeen, until, stateAt sql.NullString + createdAt, updatedAt sql.NullString + ) + if err := row.Scan( + &dev.ID, &dev.Name, &dev.Child, &dev.Platform, &dev.Topic, &online, + &lastSeen, &createdAt, &updatedAt, + &dev.Locked, &until, &stateAt, + ); err != nil { + return nil, err + } + dev.Online = online != 0 + dev.LastSeen = parseTimePtr(lastSeen) + dev.Until = parseTimePtr(until) + if t := parseTime(createdAt.String); !t.IsZero() { + dev.CreatedAt = t + } + if t := parseTime(updatedAt.String); !t.IsZero() { + dev.UpdatedAt = t + } + if t := parseTime(stateAt.String); !t.IsZero() { + dev.StateAt = t + } + return &dev, nil +} + +// ListDevices returns every registered device with its desired state, in +// registration order. Expired locks are already cleared by ExpireLocks. +func (s *Store) ListDevices(ctx context.Context) ([]Device, error) { + rows, err := s.db.QueryContext(ctx, selectDevice+` ORDER BY d.id`) + if err != nil { + return nil, fmt.Errorf("list devices: %w", err) + } + defer rows.Close() + + out := make([]Device, 0, 8) + for rows.Next() { + dev, err := scanDevice(rows) + if err != nil { + return nil, fmt.Errorf("scan device: %w", err) + } + out = append(out, *dev) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list devices: %w", err) + } + return out, nil +} + +// GetDevice returns one device with its state, or ErrNotFound. +func (s *Store) GetDevice(ctx context.Context, id int64) (*Device, error) { + dev, err := scanDevice(s.db.QueryRowContext(ctx, selectDevice+` WHERE d.id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("get device: %w", err) + } + return dev, nil +} + +// CreateDevice inserts a device and its initial (unlocked) state row, and +// appends one audit entry. The topic is derived from the name and the new id, +// so it is stable across renames. +func (s *Store) CreateDevice(ctx context.Context, name, child, platform, actor string) (*Device, error) { + name = strings.TrimSpace(name) + child = strings.TrimSpace(child) + platform = strings.ToLower(strings.TrimSpace(platform)) + + if name == "" { + return nil, errors.New("device name is required") + } + if len(name) > 80 { + return nil, errors.New("device name must be 80 characters or fewer") + } + if child == "" { + return nil, errors.New("child is required") + } + if len(child) > 40 { + return nil, errors.New("child must be 40 characters or fewer") + } + if !ValidPlatform(platform) { + return nil, errors.New(`platform must be "android" or "windows"`) + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("create device: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // The topic needs the new id, so insert with a placeholder then set it. + res, err := tx.ExecContext(ctx, + `INSERT INTO devices (name, child, platform, topic) VALUES (?, ?, ?, '')`, + name, child, platform) + if err != nil { + return nil, fmt.Errorf("insert device: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("device id: %w", err) + } + topic := fmt.Sprintf("restrict/%s_%d/config", slug(name), id) + if _, err := tx.ExecContext(ctx, + `UPDATE devices SET topic = ? WHERE id = ?`, topic, id); err != nil { + return nil, fmt.Errorf("set topic: %w", err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO state (device_id, locked, until) VALUES (?, 0, NULL)`, id); err != nil { + return nil, fmt.Errorf("insert state: %w", err) + } + if err := insertAudit(ctx, tx, actor, "device.created", &id, + fmt.Sprintf("%s (%s · %s)", name, child, platform)); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit device: %w", err) + } + return s.GetDevice(ctx, id) +} + +// UpdateDevice renames or reassigns a device. The topic and id never change, so +// a device's MQTT identity is stable. It appends one audit entry. +func (s *Store) UpdateDevice(ctx context.Context, id int64, name, child, platform, actor string) (*Device, error) { + name = strings.TrimSpace(name) + child = strings.TrimSpace(child) + platform = strings.ToLower(strings.TrimSpace(platform)) + + if name == "" { + return nil, errors.New("device name is required") + } + if len(name) > 80 { + return nil, errors.New("device name must be 80 characters or fewer") + } + if child == "" { + return nil, errors.New("child is required") + } + if len(child) > 40 { + return nil, errors.New("child must be 40 characters or fewer") + } + if !ValidPlatform(platform) { + return nil, errors.New(`platform must be "android" or "windows"`) + } + + res, err := s.db.ExecContext(ctx, + `UPDATE devices SET name = ?, child = ?, platform = ?, updated_at = datetime('now') WHERE id = ?`, + name, child, platform, id) + if err != nil { + return nil, fmt.Errorf("update device: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return nil, fmt.Errorf("update device: %w", err) + } + if n == 0 { + return nil, ErrNotFound + } + if err := s.Audit(ctx, actor, "device.updated", &id, + fmt.Sprintf("%s (%s · %s)", name, child, platform)); err != nil { + return nil, err + } + return s.GetDevice(ctx, id) +} + +// SetDeviceLock writes the desired lock state for one device and appends one +// audit entry in the same transaction. When locked is false, until is ignored. +func (s *Store) SetDeviceLock(ctx context.Context, id int64, locked bool, until *time.Time, actor, action string) (*Device, error) { + if !locked { + until = nil + } + if locked && until != nil { + t := until.UTC() + until = &t + } + + if _, err := s.GetDevice(ctx, id); err != nil { + return nil, err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("set state: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var untilArg any + detail := "unlocked" + if locked { + detail = "locked" + if until != nil { + untilArg = until.Format(time.RFC3339) + detail = "locked until " + until.Format(time.RFC3339) + } else { + detail = "locked (no end time)" + } + } + + if _, err := tx.ExecContext(ctx, ` + INSERT INTO state (device_id, locked, until, updated_at) + VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(device_id) DO UPDATE SET + locked = excluded.locked, + until = excluded.until, + updated_at = datetime('now')`, + id, boolToInt(locked), untilArg); err != nil { + return nil, fmt.Errorf("set state: %w", err) + } + if err := insertAudit(ctx, tx, actor, action, &id, detail); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit state: %w", err) + } + return s.GetDevice(ctx, id) +} + +// ExpireLocks unlocks every device whose timed lock has passed and appends one +// audit entry per device. It returns how many locks it cleared. It is safe to +// call often. +func (s *Store) ExpireLocks(ctx context.Context) (int, error) { + now := time.Now().UTC().Format(time.RFC3339) + rows, err := s.db.QueryContext(ctx, + `SELECT device_id FROM state WHERE locked = 1 AND until IS NOT NULL AND until <= ?`, now) + if err != nil { + return 0, fmt.Errorf("expire locks: %w", err) + } + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + rows.Close() + return 0, fmt.Errorf("scan expired lock: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, fmt.Errorf("expire locks: %w", err) + } + rows.Close() + + for _, id := range ids { + if _, err := s.SetDeviceLock(ctx, id, false, nil, "system", "state.expired"); err != nil { + return 0, err + } + } + return len(ids), nil +} + +// ListAudit returns the newest audit entries first (FR-15). +func (s *Store) ListAudit(ctx context.Context, limit int) ([]AuditEntry, error) { + if limit <= 0 || limit > 500 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT a.id, a.actor, a.action, a.device_id, COALESCE(d.name, ''), + COALESCE(a.detail, ''), a.created_at + FROM audit a + LEFT JOIN devices d ON d.id = a.device_id + ORDER BY a.id DESC + LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("list audit: %w", err) + } + defer rows.Close() + + out := make([]AuditEntry, 0, limit) + for rows.Next() { + var ( + e AuditEntry + deviceID sql.NullInt64 + created sql.NullString + ) + if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &deviceID, + &e.DeviceName, &e.Detail, &created); err != nil { + return nil, fmt.Errorf("scan audit: %w", err) + } + if deviceID.Valid { + id := deviceID.Int64 + e.DeviceID = &id + } + e.CreatedAt = parseTime(created.String) + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list audit: %w", err) + } + return out, nil +} + +// insertAudit appends one audit row using the given executor (a transaction or +// the database handle), so callers can audit atomically with a state change. +func insertAudit(ctx context.Context, ex interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +}, actor, action string, deviceID *int64, detail string) error { + var id any + if deviceID != nil { + id = *deviceID + } + if _, err := ex.ExecContext(ctx, + `INSERT INTO audit (actor, action, device_id, detail) VALUES (?, ?, ?, ?)`, + actor, action, id, detail); err != nil { + return fmt.Errorf("audit insert: %w", err) + } + return nil +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// parseTime accepts the RFC3339 strings this package writes and the +// "YYYY-MM-DD HH:MM:SS" strings SQLite's datetime() writes. +func parseTime(s string) time.Time { + s = strings.TrimSpace(s) + if s == "" { + return time.Time{} + } + for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z"} { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC() + } + } + return time.Time{} +} + +func parseTimePtr(ns sql.NullString) *time.Time { + if !ns.Valid { + return nil + } + t := parseTime(ns.String) + if t.IsZero() { + return nil + } + return &t +} + +// slug makes a short ASCII token for an MQTT topic from a device name. +func slug(name string) string { + var b strings.Builder + lastUnderscore := false + for _, r := range strings.ToLower(name) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + lastUnderscore = false + default: + if !lastUnderscore && b.Len() > 0 { + b.WriteByte('_') + lastUnderscore = true + } + } + } + out := strings.Trim(b.String(), "_") + if out == "" { + return "device" + } + return out +} diff --git a/internal/store/devices_test.go b/internal/store/devices_test.go new file mode 100644 index 0000000..01b1e1b --- /dev/null +++ b/internal/store/devices_test.go @@ -0,0 +1,216 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" +) + +func testStore(t *testing.T) *Store { + t.Helper() + st, err := Open(filepath.Join(t.TempDir(), "control.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(context.Background()); err != nil { + t.Fatalf("Migrate: %v", err) + } + return st +} + +func TestCreateDevice(t *testing.T) { + ctx := context.Background() + st := testStore(t) + + dev, err := st.CreateDevice(ctx, "Harry's Pixel", "Harry", "android", "sam") + if err != nil { + t.Fatalf("CreateDevice: %v", err) + } + if dev.ID == 0 { + t.Error("device id was not assigned") + } + if dev.Topic != "restrict/harry_s_pixel_1/config" { + t.Errorf("topic = %q, want restrict/harry_s_pixel_1/config", dev.Topic) + } + if dev.Locked { + t.Error("new device should start unlocked") + } + if dev.Until != nil { + t.Error("new device should have no lock end time") + } + + // Creation is audited. + entries, err := st.ListAudit(ctx, 10) + if err != nil { + t.Fatalf("ListAudit: %v", err) + } + if len(entries) != 1 || entries[0].Action != "device.created" { + t.Errorf("audit = %+v, want one device.created", entries) + } + + // A second device gets its own topic. + dev2, err := st.CreateDevice(ctx, "Finn PC", "Finn", "windows", "sam") + if err != nil { + t.Fatalf("CreateDevice second: %v", err) + } + if dev2.Topic == dev.Topic { + t.Errorf("topics must differ, both %q", dev.Topic) + } +} + +func TestCreateDeviceValidation(t *testing.T) { + ctx := context.Background() + st := testStore(t) + + for _, tc := range []struct { + name string + device string + child string + platform string + }{ + {"empty name", " ", "Harry", "android"}, + {"empty child", "Phone", " ", "android"}, + {"bad platform", "Phone", "Harry", "linux"}, + } { + if _, err := st.CreateDevice(ctx, tc.device, tc.child, tc.platform, "sam"); err == nil { + t.Errorf("%s: expected an error, got nil", tc.name) + } + } +} + +func TestUpdateDeviceKeepsTopic(t *testing.T) { + ctx := context.Background() + st := testStore(t) + + dev, err := st.CreateDevice(ctx, "Old Name", "Harry", "android", "sam") + if err != nil { + t.Fatalf("CreateDevice: %v", err) + } + updated, err := st.UpdateDevice(ctx, dev.ID, "New Name", "Finn", "windows", "sam") + if err != nil { + t.Fatalf("UpdateDevice: %v", err) + } + if updated.Name != "New Name" || updated.Child != "Finn" || updated.Platform != "windows" { + t.Errorf("updated = %+v", updated) + } + if updated.Topic != dev.Topic { + t.Errorf("topic changed on rename: %q -> %q", dev.Topic, updated.Topic) + } + + if _, err := st.UpdateDevice(ctx, 999, "X", "Y", "android", "sam"); !errors.Is(err, ErrNotFound) { + t.Errorf("update missing device: err = %v, want ErrNotFound", err) + } +} + +func TestSetDeviceLockAndAudit(t *testing.T) { + ctx := context.Background() + st := testStore(t) + + dev, err := st.CreateDevice(ctx, "Harry Phone", "Harry", "android", "sam") + if err != nil { + t.Fatalf("CreateDevice: %v", err) + } + + until := time.Now().Add(30 * time.Minute).UTC().Truncate(time.Second) + locked, err := st.SetDeviceLock(ctx, dev.ID, true, &until, "sam", "state.locked") + if err != nil { + t.Fatalf("SetDeviceLock: %v", err) + } + if !locked.Locked { + t.Fatal("device should be locked") + } + if locked.Until == nil || !locked.Until.Equal(until) { + t.Errorf("Until = %v, want %v", locked.Until, until) + } + + // The state survives a fresh read. + again, err := st.GetDevice(ctx, dev.ID) + if err != nil { + t.Fatalf("GetDevice: %v", err) + } + if !again.Locked || again.Until == nil { + t.Errorf("state not persisted: %+v", again) + } + + unlocked, err := st.SetDeviceLock(ctx, dev.ID, false, nil, "sam", "state.unlocked") + if err != nil { + t.Fatalf("unlock: %v", err) + } + if unlocked.Locked || unlocked.Until != nil { + t.Errorf("unlocked = %+v, want locked=false until=nil", unlocked) + } + + entries, err := st.ListAudit(ctx, 10) + if err != nil { + t.Fatalf("ListAudit: %v", err) + } + var created, lock, unlock int + for _, e := range entries { + switch e.Action { + case "device.created": + created++ + case "state.locked": + lock++ + case "state.unlocked": + unlock++ + } + } + if created != 1 || lock != 1 || unlock != 1 { + t.Errorf("audit counts created=%d locked=%d unlocked=%d", created, lock, unlock) + } + + if _, err := st.SetDeviceLock(ctx, 424242, true, nil, "sam", "state.locked"); !errors.Is(err, ErrNotFound) { + t.Errorf("lock missing device: err = %v, want ErrNotFound", err) + } +} + +func TestExpireLocks(t *testing.T) { + ctx := context.Background() + st := testStore(t) + + dev, err := st.CreateDevice(ctx, "Harry Phone", "Harry", "android", "sam") + if err != nil { + t.Fatalf("CreateDevice: %v", err) + } + past := time.Now().Add(-time.Minute).UTC() + if _, err := st.SetDeviceLock(ctx, dev.ID, true, &past, "sam", "state.locked"); err != nil { + t.Fatalf("SetDeviceLock: %v", err) + } + + n, err := st.ExpireLocks(ctx) + if err != nil { + t.Fatalf("ExpireLocks: %v", err) + } + if n != 1 { + t.Fatalf("ExpireLocks = %d, want 1", n) + } + dev, err = st.GetDevice(ctx, dev.ID) + if err != nil { + t.Fatalf("GetDevice: %v", err) + } + if dev.Locked || dev.Until != nil { + t.Errorf("expired lock not cleared: %+v", dev) + } + + // A second sweep is a no-op. + if n, err := st.ExpireLocks(ctx); err != nil || n != 0 { + t.Errorf("second ExpireLocks = (%d, %v), want (0, nil)", n, err) + } +} + +func TestSlug(t *testing.T) { + for in, want := range map[string]string{ + "Harry's Pixel": "harry_s_pixel", + "Finn PC": "finn_pc", + " ": "device", + "***": "device", + "a__b": "a_b", + } { + if got := slug(in); got != want { + t.Errorf("slug(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 58103c9..afba678 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -112,14 +112,7 @@ func migrationFiles() ([]migration, error) { // Audit appends one row to the append-only audit log. func (s *Store) Audit(ctx context.Context, actor, action string, deviceID *int64, detail string) error { - _, err := s.db.ExecContext(ctx, - `INSERT INTO audit (actor, action, device_id, detail) VALUES (?, ?, ?, ?)`, - actor, action, deviceID, detail, - ) - if err != nil { - return fmt.Errorf("audit insert: %w", err) - } - return nil + return insertAudit(ctx, s.db, actor, action, deviceID, detail) } // CountDevices returns the number of registered devices (0 in Phase 1). diff --git a/internal/web/devices.go b/internal/web/devices.go new file mode 100644 index 0000000..e057c1d --- /dev/null +++ b/internal/web/devices.go @@ -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 +} diff --git a/internal/web/devices_test.go b/internal/web/devices_test.go new file mode 100644 index 0000000..13249ad --- /dev/null +++ b/internal/web/devices_test.go @@ -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'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'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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 595e138..a30dcfc 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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(), } } diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 652d312..20683fc 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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%; } } diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index cfb3153..0c93c1f 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -27,21 +27,39 @@

Dashboard

-

Phase 1 skeleton — the Control Service is up and answering.

+

Lock or unlock a device. State is saved in SQLite and written to the audit log.

+ {{if .Flash}}

{{.Flash}}

{{end}} + {{if .ErrMsg}}{{end}} + {{template "status.html" .}}

Devices

- 0 registered +
+ {{len .Devices}} registered + Add device +
-
-

No devices yet

-

Device registration arrives in Phase 2. Until then the - dashboard, session login and /health are the working pieces.

+ {{if .Devices}} + {{template "device-cards.html" .}} + {{else}} +
+

No devices yet

+

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.

+
+ {{end}} +
+ +
+
+

Activity

+ append-only audit
+ {{template "audit-list.html" .}}
diff --git a/internal/web/templates/device-form.html b/internal/web/templates/device-form.html new file mode 100644 index 0000000..be5399b --- /dev/null +++ b/internal/web/templates/device-form.html @@ -0,0 +1,80 @@ +{{define "device-form.html"}} + + + + + + {{.Title}} — Internet Use Control + + + + + + +
+
+

← Dashboard

+

{{.Heading}}

+

{{.Sub}}

+
+ + {{if .Error}}{{end}} + +
+
+ + + + + + +
+ Platform +
+ + +
+
+ +
+ + Cancel +
+
+
+
+ +
+ Control Service · {{.Version}} · {{.AppEnv}} + · + {{year}} Family Home Lab +
+ +{{end}} diff --git a/internal/web/templates/partials/audit-list.html b/internal/web/templates/partials/audit-list.html new file mode 100644 index 0000000..dcf9b2c --- /dev/null +++ b/internal/web/templates/partials/audit-list.html @@ -0,0 +1,14 @@ +{{define "audit-list.html"}}
    + {{range .Audit}} +
  • + {{.ActionLabel}} + + {{if .DeviceName}}{{.DeviceName}}{{end}} + {{if .Detail}}{{.Detail}}{{end}} + + {{.Actor}} · {{.CreatedAtLocal}} +
  • + {{else}} +
  • No activity yet.
  • + {{end}} +
{{end}} diff --git a/internal/web/templates/partials/device-cards.html b/internal/web/templates/partials/device-cards.html new file mode 100644 index 0000000..3ad0cf1 --- /dev/null +++ b/internal/web/templates/partials/device-cards.html @@ -0,0 +1,49 @@ +{{define "device-cards.html"}}
+ {{range .Devices}} +
+
+
+

{{.Name}}

+

{{.Child}} · {{.PlatformLabel}}

+
+ + {{.StateLabel}} + +
+ +

+ {{if .Locked}}Blocked{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}} +

+ {{if .Until}}

Lifts {{.UntilLocal}}

{{end}} + +
+
+ + + +
+ {{if .Locked}} +
+ + +
+ {{end}} + Edit +
+ +

+ + + {{if .Online}}online{{else}}offline{{end}} + + {{if .HasLastSeen}}· last seen {{.LastSeen}}{{else}}· never connected{{end}} +

+

{{.Topic}}

+
+ {{end}} +
{{end}}