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

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.
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).

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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