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

View File

@@ -1,5 +1,5 @@
IMAGE ?= webrestrict/control:0.1.0-phase1 IMAGE ?= webrestrict/control:0.2.0-phase2
BUILD_VERSION ?= 0.1.0-phase1 BUILD_VERSION ?= 0.2.0-phase2
.PHONY: help test vet build up down restart logs ps hash-password fmt .PHONY: help test vet build up down restart logs ps hash-password fmt

View File

@@ -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 per-device state and usage, and serves the web dashboard. Only it publishes
device config. 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. - Go service with its own image (`Dockerfile`) and compose file.
- SQLite schema for the whole data model (§7), applied through versioned - 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`. - `GET /health`.
- HTMX + server-rendered dashboard styled with the Family Home Lab design - HTMX + server-rendered dashboard styled with the Family Home Lab design
system (`tokens.css` copied in). 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. Enforcement, MQTT, schedules, blocklists and the clients arrive in later
Nothing here touches the router, Pi-hole, or DNS. phases. Nothing here touches the router, Pi-hole, or DNS.
## Quick start (on `.13`) ## Quick start (on `.13`)
@@ -34,7 +40,7 @@ docker compose up -d --build
# 3. Prove it. # 3. Prove it.
curl -s http://127.0.0.1:8791/health 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 <http://127.0.0.1:8791/> in a browser. It redirects to `/login`. Sign in Open <http://127.0.0.1:8791/> 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: Generate a bcrypt hash without host Go:
```bash ```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' hash-password 'your-password'
``` ```
## Endpoints (Phase 1) ## Endpoints (Phase 2)
| Method | Path | Notes | | Method | Path | Notes |
|---|---|---| |---|---|---|
@@ -75,8 +81,17 @@ docker run --rm --entrypoint /app/control webrestrict/control:0.1.0-phase1 \
| `GET` | `/login` | Session login form. | | `GET` | `/login` | Session login form. |
| `POST` | `/login` | Verifies credentials, sets a signed session cookie. | | `POST` | `/login` | Verifies credentials, sets a signed session cookie. |
| `POST` | `/logout` | Clears the session. | | `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` | `/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 ## Development
@@ -95,11 +110,12 @@ make logs # follow container logs
``` ```
cmd/control/ service entry point (+ hash-password helper) cmd/control/ service entry point (+ hash-password helper)
internal/config/ environment configuration 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/store/migrations/001_init.sql
internal/auth/ signed-cookie session login + CSRF internal/auth/ signed-cookie session login + CSRF
internal/web/ HTTP handlers, templates, static assets 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 static/ tokens.css (Family Home Lab), app.css, htmx, Inter fonts
Dockerfile own image (multi-stage, CGO-free) Dockerfile own image (multi-stage, CGO-free)
docker-compose.yml own container, port 8791 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` `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. (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 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 admin API (Phase 8).
- No Hub contact (Phase 9). - No Hub contact (Phase 9).

View File

@@ -1,8 +1,8 @@
// Command control is the Internet Use Control Control Service. // Command control is the Internet Use Control Control Service.
// //
// It is the single source of truth for the family's device rules // 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 // (SYSTEM_MAP.md). Phase 2: device registry, desired state in SQLite, manual
// environment, and a separate session login. // lock/unlock from the dashboard, and the audit log.
package main package main
import ( import (

View File

@@ -9,8 +9,8 @@ services:
build: build:
context: . context: .
args: args:
VERSION: "0.1.0-phase1" VERSION: "0.2.0-phase2"
image: webrestrict/control:0.1.0-phase1 image: webrestrict/control:0.2.0-phase2
container_name: webrestrict-control container_name: webrestrict-control
restart: unless-stopped restart: unless-stopped
env_file: env_file:

434
internal/store/devices.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -112,14 +112,7 @@ func migrationFiles() ([]migration, error) {
// Audit appends one row to the append-only audit log. // 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 { func (s *Store) Audit(ctx context.Context, actor, action string, deviceID *int64, detail string) error {
_, err := s.db.ExecContext(ctx, return insertAudit(ctx, s.db, actor, action, deviceID, detail)
`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
} }
// CountDevices returns the number of registered devices (0 in Phase 1). // CountDevices returns the number of registered devices (0 in Phase 1).

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("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard)) mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /partials/status", s.requireAuth(s.handleStatusPartial)) 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") staticSub, err := fs.Sub(staticFS, "static")
if err != nil { if err != nil {
@@ -223,45 +229,70 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
} }
type statusData struct {
DBHealthy bool
DBPath string
ServerTime string
ServerUp string
}
type dashboardData struct { type dashboardData struct {
statusData
Title string Title string
Username string Username string
CSRF string CSRF string
Version string Version string
AppEnv string AppEnv string
DBPath string Devices []deviceView
DeviceCount int Audit []auditView
DBHealthy bool Flash string
ServerTime string ErrMsg string
ServerUp string
} }
var serverStarted = time.Now() var serverStarted = time.Now()
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
data := s.dashboardData(w, r) ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
s.render(w, r, "dashboard.html", data)
}
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() defer cancel()
count, err := s.store.CountDevices(ctx) if n, err := s.store.ExpireLocks(ctx); err != nil {
return dashboardData{ 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", Title: "Dashboard",
Username: s.cfg.AdminUsername, Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r), CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version, Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv, 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) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
s.renderFragment(w, r, "status.html", s.buildStatusData(ctx))
}
func (s *Server) buildStatusData(ctx context.Context) statusData {
return statusData{
DBHealthy: s.store.Ping(ctx) == nil,
DBPath: s.cfg.DBPath, DBPath: s.cfg.DBPath,
DeviceCount: count,
DBHealthy: err == nil,
ServerTime: time.Now().Format("Mon 02 Jan 2006 15:04:05"), ServerTime: time.Now().Format("Mon 02 Jan 2006 15:04:05"),
ServerUp: time.Since(serverStarted).Round(time.Second).String(), ServerUp: time.Since(serverStarted).Round(time.Second).String(),
} }

View File

@@ -297,6 +297,173 @@ code {
.login-foot { color: rgba(255, 255, 255, 0.6); } .login-foot { color: rgba(255, 255, 255, 0.6); }
.app-foot__sep { opacity: 0.5; } .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 ---------- */ /* ---------- Responsive ---------- */
@media (max-width: 760px) { @media (max-width: 760px) {
@@ -305,4 +472,6 @@ code {
.container { padding: var(--space-lg) var(--space-md); gap: var(--space-xl); } .container { padding: var(--space-lg) var(--space-md); gap: var(--space-xl); }
.nav-bar { padding: var(--space-sm) var(--space-md); } .nav-bar { padding: var(--space-sm) var(--space-md); }
.nav-bar__user { display: none; } .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"> <main class="container">
<header class="page-head"> <header class="page-head">
<h1 class="heading-1">Dashboard</h1> <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> </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" .}} {{template "status.html" .}}
<section class="section" aria-labelledby="devices-title"> <section class="section" aria-labelledby="devices-title">
<div class="section__head"> <div class="section__head">
<h2 id="devices-title" class="heading-2">Devices</h2> <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>
{{if .Devices}}
{{template "device-cards.html" .}}
{{else}}
<div class="empty-state"> <div class="empty-state">
<p class="empty-state__title">No devices yet</p> <p class="empty-state__title">No devices yet</p>
<p class="empty-state__body">Device registration arrives in Phase 2. Until then the <p class="empty-state__body">Add Harry's phone or Finn's computer to give it a name,
dashboard, session login and <code>/health</code> are the working pieces.</p> a child, and a platform. Nothing is enforced until a later phase.</p>
</div> </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> </section>
</main> </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}}