Phase 1: Control Service skeleton (Go, SQLite, session login, Docker, Caddy)

- Go service with its own multi-stage Dockerfile and docker-compose.yml; runs
  as container webrestrict-control on port 8791.
- SQLite schema for the full AGENTS.md §7 data model via versioned migrations
  (PRAGMA user_version); audit log is append-only.
- Configuration from the process environment; the real env file is gitignored
  and a template is committed. No secrets in the repo.
- Separate session login (signed HMAC cookie + CSRF) for Sam.
- GET /health; HTMX + server-rendered dashboard styled with the Family Home Lab
  tokens.css (copied) and Inter fonts; dark mode; phone-friendly.
- Caddy vhost for webrestrict.lab.audasmedia.com.au applied on .35 (Let's
  Encrypt cert issued); route recorded in deploy/caddy/webrestrict.caddy.
- Unit tests for config, auth and store.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-13 14:11:38 +11:00
parent 5368225f23
commit c83c9df0a3
29 changed files with 2119 additions and 0 deletions

207
internal/auth/auth.go Normal file
View File

@@ -0,0 +1,207 @@
// Package auth implements the separate session login for the Control Service
// (AGENTS.md §9 Auth). Sessions are stateless, signed cookies. A random CSRF
// cookie uses the double-submit pattern for forms.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookie = "control_session"
csrfCookie = "control_csrf"
)
// Manager issues and validates sessions.
type Manager struct {
// Secret signs every cookie. Required.
Secret []byte
// TTL is the session lifetime.
TTL time.Duration
// Secure sets the Secure cookie attribute. Keep true behind HTTPS.
Secure bool
// Username is the single allowed login name.
Username string
// passwordHash is a bcrypt hash, or nil when only a plaintext password is set.
passwordHash []byte
// plaintext is the fallback password, or empty when a hash is set.
plaintext string
}
// New builds a Manager. Exactly one of bcryptHash or plaintext must be set.
func New(secret []byte, ttl time.Duration, secure bool, username, bcryptHash, plaintext string) (*Manager, error) {
if len(secret) < 16 {
return nil, errors.New("session secret too short")
}
m := &Manager{Secret: secret, TTL: ttl, Secure: secure, Username: username}
switch {
case bcryptHash != "":
if _, err := bcrypt.Cost([]byte(bcryptHash)); err != nil {
return nil, fmt.Errorf("CONTROL_ADMIN_PASSWORD_HASH is not a valid bcrypt hash: %w", err)
}
m.passwordHash = []byte(bcryptHash)
case plaintext != "":
m.plaintext = plaintext
default:
return nil, errors.New("no admin password configured")
}
return m, nil
}
// Hashed reports whether the manager verifies against a bcrypt hash.
func (m *Manager) Hashed() bool { return m.passwordHash != nil }
// CheckCredentials validates a username and password in constant time where
// possible. It never reveals which part was wrong.
func (m *Manager) CheckCredentials(username, password string) bool {
userOK := subtle.ConstantTimeCompare([]byte(username), []byte(m.Username)) == 1
passOK := false
if m.passwordHash != nil {
passOK = bcrypt.CompareHashAndPassword(m.passwordHash, []byte(password)) == nil
} else {
passOK = subtle.ConstantTimeCompare([]byte(password), []byte(m.plaintext)) == 1
}
return userOK && passOK
}
// Issue writes a signed session cookie and the matching CSRF cookie.
func (m *Manager) Issue(w http.ResponseWriter) {
csrf := randomToken()
expires := time.Now().Add(m.TTL).Unix()
payload := strings.Join([]string{m.Username, csrf, strconv.FormatInt(expires, 10)}, "\n")
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: m.sign(payload),
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expires, 0),
MaxAge: int(m.TTL.Seconds()),
})
http.SetCookie(w, &http.Cookie{
Name: csrfCookie,
Value: csrf,
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(expires, 0),
MaxAge: int(m.TTL.Seconds()),
})
}
// Clear removes the session and CSRF cookies.
func (m *Manager) Clear(w http.ResponseWriter) {
for _, name := range []string{sessionCookie, csrfCookie} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
}
// Authenticated reports whether the request carries a valid, unexpired session.
func (m *Manager) Authenticated(r *http.Request) bool {
c, err := r.Cookie(sessionCookie)
if err != nil {
return false
}
payload, ok := m.verify(c.Value)
if !ok {
return false
}
parts := strings.Split(payload, "\n")
if len(parts) != 3 || parts[0] != m.Username {
return false
}
expires, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || time.Now().Unix() > expires {
return false
}
return true
}
// EnsureCSRF returns the CSRF token for a form, setting the cookie if absent.
func (m *Manager) EnsureCSRF(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(csrfCookie); err == nil && c.Value != "" {
return c.Value
}
token := randomToken()
http.SetCookie(w, &http.Cookie{
Name: csrfCookie,
Value: token,
Path: "/",
HttpOnly: true,
Secure: m.Secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(m.TTL.Seconds()),
})
return token
}
// CheckCSRF compares the submitted form token with the CSRF cookie.
func (m *Manager) CheckCSRF(r *http.Request) bool {
c, err := r.Cookie(csrfCookie)
if err != nil || c.Value == "" {
return false
}
form := r.PostFormValue("csrf")
return subtle.ConstantTimeCompare([]byte(form), []byte(c.Value)) == 1
}
func (m *Manager) sign(payload string) string {
mac := hmac.New(sha256.New, m.Secret)
mac.Write([]byte(payload))
sig := mac.Sum(nil)
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
base64.RawURLEncoding.EncodeToString(sig)
}
func (m *Manager) verify(token string) (string, bool) {
encoded, sigEncoded, ok := strings.Cut(token, ".")
if !ok {
return "", false
}
payload, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", false
}
gotSig, err := base64.RawURLEncoding.DecodeString(sigEncoded)
if err != nil {
return "", false
}
mac := hmac.New(sha256.New, m.Secret)
mac.Write(payload)
if !hmac.Equal(gotSig, mac.Sum(nil)) {
return "", false
}
return string(payload), true
}
func randomToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("auth: random source failed: " + err.Error())
}
return base64.RawURLEncoding.EncodeToString(b)
}

112
internal/auth/auth_test.go Normal file
View File

@@ -0,0 +1,112 @@
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func testManager(t *testing.T) *Manager {
t.Helper()
m, err := New([]byte("0123456789abcdef0123456789abcdef"), time.Hour, false, "sam", "", "hunter2")
if err != nil {
t.Fatalf("New: %v", err)
}
return m
}
func TestCredentials(t *testing.T) {
m := testManager(t)
if !m.CheckCredentials("sam", "hunter2") {
t.Error("valid credentials rejected")
}
if m.CheckCredentials("sam", "wrong") {
t.Error("wrong password accepted")
}
if m.CheckCredentials("finn", "hunter2") {
t.Error("wrong username accepted")
}
}
func TestSessionRoundTripAndTamper(t *testing.T) {
m := testManager(t)
rec := httptest.NewRecorder()
m.Issue(rec)
res := rec.Result()
req := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range res.Cookies() {
req.AddCookie(c)
}
if !m.Authenticated(req) {
t.Fatal("issued session is not authenticated")
}
// Tamper with the session value: the HMAC must reject it.
tampered := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range res.Cookies() {
if c.Name == sessionCookie {
c.Value = c.Value[:len(c.Value)-2] + "xx"
}
tampered.AddCookie(c)
}
if m.Authenticated(tampered) {
t.Fatal("tampered session accepted")
}
}
func TestCSRF(t *testing.T) {
m := testManager(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/login", nil)
token := m.EnsureCSRF(rec, req)
if token == "" {
t.Fatal("empty csrf token")
}
// The token is delivered as a cookie; a matching form value passes.
form := httptest.NewRequest(http.MethodPost, "/login", nil)
form.PostForm = map[string][]string{"csrf": {token}}
for _, c := range rec.Result().Cookies() {
form.AddCookie(c)
}
if !m.CheckCSRF(form) {
t.Error("matching csrf rejected")
}
// A different form value fails.
bad := httptest.NewRequest(http.MethodPost, "/login", nil)
bad.PostForm = map[string][]string{"csrf": {"not-the-token"}}
for _, c := range rec.Result().Cookies() {
bad.AddCookie(c)
}
if m.CheckCSRF(bad) {
t.Error("mismatched csrf accepted")
}
// No cookie at all fails.
none := httptest.NewRequest(http.MethodPost, "/login", nil)
none.PostForm = map[string][]string{"csrf": {token}}
if m.CheckCSRF(none) {
t.Error("csrf accepted without cookie")
}
}
func TestExpiredSession(t *testing.T) {
m, err := New([]byte("0123456789abcdef0123456789abcdef"), -time.Minute, false, "sam", "", "hunter2")
if err != nil {
t.Fatalf("New: %v", err)
}
rec := httptest.NewRecorder()
m.Issue(rec)
req := httptest.NewRequest(http.MethodGet, "/", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
if m.Authenticated(req) {
t.Error("expired session accepted")
}
}

114
internal/config/config.go Normal file
View File

@@ -0,0 +1,114 @@
// Package config loads the Control Service configuration from the process
// environment. Every secret comes from the environment (environment.d); nothing
// is compiled into the binary and nothing is read from a committed file.
package config
import (
"errors"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"
)
// Config is the fully resolved runtime configuration.
type Config struct {
// Addr is the listen address, for example ":8791".
Addr string
// DBPath is the SQLite database file, for example "/data/control.db".
DBPath string
// SessionSecret signs the session cookie. Required, >= 16 bytes.
SessionSecret []byte
// AdminUsername is the single dashboard login name (Sam).
AdminUsername string
// AdminPassword is the plaintext password. Optional if AdminPasswordHash is set.
AdminPassword string
// AdminPasswordHash is a bcrypt hash. Optional if AdminPassword is set.
AdminPasswordHash string
// CookieSecure sets the Secure attribute on cookies. Keep true behind HTTPS.
CookieSecure bool
// SessionTTL is how long a session cookie stays valid.
SessionTTL time.Duration
// LogLevel is the slog level.
LogLevel slog.Level
// AppEnv is a label such as "production" or "development".
AppEnv string
// Version is injected at build time.
Version string
}
// Load reads and validates the configuration.
func Load() (*Config, error) {
cfg := &Config{
Addr: env("CONTROL_ADDR", ":8791"),
DBPath: env("CONTROL_DB_PATH", "/data/control.db"),
AdminUsername: env("CONTROL_ADMIN_USERNAME", "sam"),
AdminPassword: os.Getenv("CONTROL_ADMIN_PASSWORD"),
AdminPasswordHash: os.Getenv("CONTROL_ADMIN_PASSWORD_HASH"),
CookieSecure: envBool("CONTROL_COOKIE_SECURE", true),
AppEnv: env("CONTROL_APP_ENV", "production"),
}
secret := os.Getenv("CONTROL_SESSION_SECRET")
if len(secret) < 16 {
return nil, errors.New("CONTROL_SESSION_SECRET must be set and at least 16 characters")
}
cfg.SessionSecret = []byte(secret)
if cfg.AdminPassword == "" && cfg.AdminPasswordHash == "" {
return nil, errors.New("set CONTROL_ADMIN_PASSWORD or CONTROL_ADMIN_PASSWORD_HASH")
}
if cfg.AdminUsername == "" {
return nil, errors.New("CONTROL_ADMIN_USERNAME must not be empty")
}
ttl, err := time.ParseDuration(env("CONTROL_SESSION_TTL", "12h"))
if err != nil {
return nil, fmt.Errorf("CONTROL_SESSION_TTL: %w", err)
}
cfg.SessionTTL = ttl
lvl, err := parseLevel(env("CONTROL_LOG_LEVEL", "info"))
if err != nil {
return nil, err
}
cfg.LogLevel = lvl
return cfg, nil
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func envBool(key string, fallback bool) bool {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
func parseLevel(s string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug, nil
case "info", "":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return 0, fmt.Errorf("CONTROL_LOG_LEVEL: unknown level %q", s)
}
}

View File

@@ -0,0 +1,52 @@
package config
import (
"log/slog"
"testing"
"time"
)
func TestLoadDefaultsAndValidation(t *testing.T) {
t.Setenv("CONTROL_SESSION_SECRET", "0123456789abcdef")
t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2")
t.Setenv("CONTROL_ADDR", "")
t.Setenv("CONTROL_DB_PATH", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Addr != ":8791" {
t.Errorf("Addr = %q, want :8791", cfg.Addr)
}
if cfg.DBPath != "/data/control.db" {
t.Errorf("DBPath = %q, want /data/control.db", cfg.DBPath)
}
if cfg.AdminUsername != "sam" {
t.Errorf("AdminUsername = %q, want sam", cfg.AdminUsername)
}
if cfg.SessionTTL != 12*time.Hour {
t.Errorf("SessionTTL = %v, want 12h", cfg.SessionTTL)
}
if cfg.LogLevel != slog.LevelInfo {
t.Errorf("LogLevel = %v, want info", cfg.LogLevel)
}
if !cfg.CookieSecure {
t.Error("CookieSecure = false, want true by default")
}
}
func TestLoadRequiresSecretAndPassword(t *testing.T) {
t.Setenv("CONTROL_SESSION_SECRET", "")
t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2")
if _, err := Load(); err == nil {
t.Error("expected error when session secret is missing")
}
t.Setenv("CONTROL_SESSION_SECRET", "0123456789abcdef")
t.Setenv("CONTROL_ADMIN_PASSWORD", "")
t.Setenv("CONTROL_ADMIN_PASSWORD_HASH", "")
if _, err := Load(); err == nil {
t.Error("expected error when no admin password is set")
}
}

View File

@@ -0,0 +1,89 @@
-- Migration 001 — Phase 1 foundation (AGENTS.md §7).
-- Tables may grow in later phases. Never hard-delete devices or history.
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
child TEXT NOT NULL,
platform TEXT NOT NULL CHECK (platform IN ('android', 'windows')),
topic TEXT NOT NULL UNIQUE,
online INTEGER NOT NULL DEFAULT 0,
last_seen TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS schedules (
id INTEGER PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id),
weekday INTEGER NOT NULL CHECK (weekday BETWEEN 0 AND 6), -- 0 = Monday
start_time TEXT NOT NULL, -- HH:MM
end_time TEXT NOT NULL, -- HH:MM
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_schedules_device ON schedules(device_id);
CREATE TABLE IF NOT EXISTS blocklist (
id INTEGER PRIMARY KEY,
device_id INTEGER REFERENCES devices(id), -- NULL = applies to every device
kind TEXT NOT NULL CHECK (kind IN ('app', 'site')),
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_blocklist_device ON blocklist(device_id);
CREATE TABLE IF NOT EXISTS allowlist (
id INTEGER PRIMARY KEY,
device_id INTEGER REFERENCES devices(id), -- NULL = applies to every device
kind TEXT NOT NULL CHECK (kind IN ('app', 'site')),
value TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_allowlist_device ON allowlist(device_id);
-- Desired state per device. The Control Service is the only writer.
CREATE TABLE IF NOT EXISTS state (
device_id INTEGER PRIMARY KEY REFERENCES devices(id),
locked INTEGER NOT NULL DEFAULT 0,
until TEXT,
allow_json TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS telemetry (
id INTEGER PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id),
app TEXT,
domain TEXT,
seconds INTEGER NOT NULL DEFAULT 0,
day TEXT NOT NULL, -- YYYY-MM-DD, local day
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_telemetry_device_day ON telemetry(device_id, day);
CREATE TABLE IF NOT EXISTS overrides (
id INTEGER PRIMARY KEY,
device_id INTEGER NOT NULL REFERENCES devices(id),
actor TEXT NOT NULL,
action TEXT NOT NULL,
until TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_overrides_device ON overrides(device_id);
-- Append-only audit log. Size-capped by a later phase, never edited in place.
CREATE TABLE IF NOT EXISTS audit (
id INTEGER PRIMARY KEY,
actor TEXT NOT NULL,
action TEXT NOT NULL,
device_id INTEGER REFERENCES devices(id),
detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit(created_at);

132
internal/store/store.go Normal file
View File

@@ -0,0 +1,132 @@
// Package store owns the SQLite database. It is the single source of truth for
// the Control Service (SYSTEM_MAP.md).
package store
import (
"context"
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo)
)
//go:embed migrations/*.sql
var migrationFS embed.FS
// Store wraps the database handle.
type Store struct {
db *sql.DB
}
// Open opens (creating if needed) the SQLite database at path.
func Open(path string) (*Store, error) {
dsn := fmt.Sprintf(
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)",
path,
)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// modernc/sqlite serialises writes; one connection avoids lock churn.
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
return &Store{db: db}, nil
}
// DB exposes the handle for read-only queries in later phases.
func (s *Store) DB() *sql.DB { return s.db }
// Ping verifies the database is reachable.
func (s *Store) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Migrate applies every embedded migration that has not run yet. It tracks the
// applied version with PRAGMA user_version, so reruns are safe.
func (s *Store) Migrate(ctx context.Context) error {
files, err := migrationFiles()
if err != nil {
return err
}
var version int
if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read user_version: %w", err)
}
for i := version; i < len(files); i++ {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %d: %w", i+1, err)
}
if _, err := tx.ExecContext(ctx, files[i].sql); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %s: %w", files[i].name, err)
}
if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("set user_version %d: %w", i+1, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", files[i].name, err)
}
}
return nil
}
type migration struct {
name string
sql string
}
func migrationFiles() ([]migration, error) {
entries, err := fs.ReadDir(migrationFS, "migrations")
if err != nil {
return nil, fmt.Errorf("read migrations: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names) // 001_, 002_, ... apply in order
out := make([]migration, 0, len(names))
for _, n := range names {
b, err := fs.ReadFile(migrationFS, "migrations/"+n)
if err != nil {
return nil, fmt.Errorf("read migration %s: %w", n, err)
}
out = append(out, migration{name: n, sql: string(b)})
}
return out, nil
}
// 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
}
// CountDevices returns the number of registered devices (0 in Phase 1).
func (s *Store) CountDevices(ctx context.Context) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM devices`).Scan(&n); err != nil {
return 0, fmt.Errorf("count devices: %w", err)
}
return n, nil
}

View File

@@ -0,0 +1,52 @@
package store
import (
"context"
"path/filepath"
"testing"
)
func TestMigrateAndFoundation(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "control.db")
st, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
defer st.Close()
if err := st.Migrate(ctx); err != nil {
t.Fatalf("Migrate: %v", err)
}
// Migrate is idempotent.
if err := st.Migrate(ctx); err != nil {
t.Fatalf("second Migrate: %v", err)
}
n, err := st.CountDevices(ctx)
if err != nil {
t.Fatalf("CountDevices: %v", err)
}
if n != 0 {
t.Errorf("CountDevices = %d, want 0", n)
}
if err := st.Audit(ctx, "sam", "test", nil, "phase1"); err != nil {
t.Fatalf("Audit: %v", err)
}
// Every §7 table must exist after migration.
want := []string{
"devices", "schedules", "blocklist", "allowlist",
"state", "telemetry", "overrides", "audit",
}
for _, table := range want {
var name string
err := st.DB().QueryRowContext(ctx,
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name)
if err != nil {
t.Errorf("table %q missing: %v", table, err)
}
}
}

284
internal/web/server.go Normal file
View File

@@ -0,0 +1,284 @@
// Package web is the HTTP surface of the Control Service: /health, the session
// login, and the server-rendered HTMX dashboard (AGENTS.md §5, §9).
package web
import (
"context"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"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"
)
//go:embed templates/*.html templates/partials/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
// Server holds the dependencies for the HTTP handlers.
type Server struct {
cfg *config.Config
store *store.Store
auth *auth.Manager
tmpl *template.Template
log *slog.Logger
}
// New builds a Server and parses the embedded templates.
func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger) (*Server, error) {
funcs := template.FuncMap{
"year": func() int { return time.Now().Year() },
"now": func() time.Time { return time.Now() },
"clock": func() string {
return time.Now().Format("15:04:05 MST")
},
}
tmpl, err := template.New("").Funcs(funcs).ParseFS(templateFS,
"templates/*.html", "templates/partials/*.html")
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log}, nil
}
// Handler returns the fully wrapped HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /partials/status", s.requireAuth(s.handleStatusPartial))
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("web: embedded static dir missing: " + err.Error())
}
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
return s.recoverer(s.securityHeaders(s.requestLogger(mux)))
}
// ---------- middleware ----------
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (s *Server) requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.log.Info("http",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"remote", r.RemoteAddr,
"duration", time.Since(start).String(),
)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "same-origin")
h.Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
s.log.Error("panic", "err", rec, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.auth.Authenticated(r) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
// ---------- handlers ----------
type healthResponse struct {
Status string `json:"status"`
Service string `json:"service"`
Version string `json:"version"`
Database string `json:"database"`
Time string `json:"time"`
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
Status: "ok",
Service: "control-service",
Version: s.cfg.Version,
Database: "ok",
Time: time.Now().UTC().Format(time.RFC3339),
}
code := http.StatusOK
if err := s.store.Ping(r.Context()); err != nil {
resp.Status = "error"
resp.Database = "error"
code = http.StatusServiceUnavailable
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(resp)
}
type loginData struct {
CSRF string
Error string
Username string
}
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if s.auth.Authenticated(r) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
s.render(w, r, "login.html", loginData{CSRF: s.auth.EnsureCSRF(w, r)})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
if !s.auth.CheckCSRF(r) {
s.log.Warn("login rejected: bad csrf token", "remote", r.RemoteAddr)
_ = s.store.Audit(r.Context(), username, "login.csrf_rejected", nil, r.RemoteAddr)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Session expired. Please try again.",
Username: username,
})
return
}
if !s.auth.CheckCredentials(username, r.PostFormValue("password")) {
s.log.Warn("login failed", "user", username, "remote", r.RemoteAddr)
_ = s.store.Audit(r.Context(), username, "login.failed", nil, r.RemoteAddr)
w.WriteHeader(http.StatusUnauthorized)
s.render(w, r, "login.html", loginData{
CSRF: s.auth.EnsureCSRF(w, r),
Error: "Incorrect username or password.",
Username: username,
})
return
}
s.auth.Issue(w)
_ = s.store.Audit(r.Context(), username, "login.success", nil, r.RemoteAddr)
s.log.Info("login success", "user", username, "remote", r.RemoteAddr)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if s.auth.Authenticated(r) && s.auth.CheckCSRF(r) {
_ = s.store.Audit(r.Context(), s.cfg.AdminUsername, "logout", nil, r.RemoteAddr)
}
s.auth.Clear(w)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
type dashboardData struct {
Title string
Username string
CSRF string
Version string
AppEnv string
DBPath string
DeviceCount int
DBHealthy bool
ServerTime string
ServerUp 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)
}
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()
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) render(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}
func (s *Server) renderFragment(w http.ResponseWriter, _ *http.Request, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.log.Error("render fragment", "template", name, "err", err)
http.Error(w, "template error", http.StatusInternalServerError)
}
}

308
internal/web/static/app.css Normal file
View File

@@ -0,0 +1,308 @@
/* Internet Use Control — Control Service UI
Built on the Family Home Lab design tokens (tokens.css).
Rules: canvas-soft app background, white cards, hairlines, one action blue,
pill CTAs, 8px grid, touch targets >= 44px, dark mode from the tokens. */
*,
*::before,
*::after { box-sizing: border-box; }
html { font-size: 16px; }
body {
margin: 0;
background: var(--canvas-soft);
color: var(--ink-secondary);
font: var(--type-body-md);
-webkit-font-smoothing: antialiased;
min-height: 100dvh;
}
h1, h2, h3 { margin: 0; color: var(--ink); }
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
code {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
background: var(--surface-2);
padding: 1px 5px;
border-radius: var(--rounded-xs);
}
/* ---------- Login ---------- */
.login-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-lg);
padding: var(--space-lg);
background:
radial-gradient(1200px 600px at 15% 0%, #2b3d93 0%, transparent 60%),
var(--secondary);
}
.login-band {
width: 100%;
max-width: 960px;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: var(--space-xxl);
align-items: center;
padding: var(--space-xxl) 0;
}
.login-eyebrow {
margin: 0 0 var(--space-sm);
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.72);
}
.login-headline {
margin: 0;
font: var(--type-display-1);
letter-spacing: -1.5px;
color: #ffffff;
}
.login-sub {
margin: var(--space-md) 0 0;
font: var(--type-body-md);
color: rgba(255, 255, 255, 0.78);
}
.auth-card {
background: var(--surface);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-2);
padding: var(--space-xl);
}
.auth-card__title {
font: var(--type-heading-2);
margin-bottom: var(--space-lg);
}
.auth-form { display: grid; gap: var(--space-md); }
.auth-error {
margin: 0 0 var(--space-md);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--sticker-red);
border-left-width: 3px;
border-radius: var(--rounded-sm);
color: var(--sticker-red);
background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface));
font: var(--type-body-sm);
}
.field { display: grid; gap: var(--space-xs); }
.field__label {
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
}
.text-input {
width: 100%;
min-height: 44px;
padding: 10px 12px;
font: var(--type-body-md);
color: var(--ink);
background: var(--surface);
border: 1px solid #dddddd;
border-radius: var(--rounded-xs);
}
[data-theme="dark"] .text-input { border-color: var(--hairline); }
.text-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--shadow-level-1), 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
}
/* ---------- Buttons ---------- */
.button-primary,
.button-utility {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 10px 20px;
font: var(--type-button);
cursor: pointer;
transition: background 120ms ease, transform 80ms ease, box-shadow 120ms ease;
}
.button-primary {
border: 0;
border-radius: var(--rounded-full);
background: var(--primary);
color: var(--on-primary);
}
.button-primary:active { background: var(--primary-active); transform: scale(0.97); }
.button-primary:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: 2px; }
.button-utility {
border: 1px solid var(--hairline);
border-radius: var(--rounded-md);
background: var(--surface);
color: var(--ink);
padding: 8px 14px;
}
.button-utility:hover { box-shadow: var(--shadow-level-1); }
/* ---------- App shell ---------- */
.app { display: flex; flex-direction: column; min-height: 100dvh; }
.nav-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-sm) var(--space-lg);
background: var(--surface);
border-bottom: 1px solid var(--hairline);
}
.nav-bar__brand {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
color: var(--ink);
font: var(--type-title);
}
.nav-bar__brand:hover { text-decoration: none; }
.nav-bar__mark {
width: 12px; height: 12px; border-radius: var(--rounded-full);
background: var(--primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
}
.nav-bar__right { display: flex; align-items: center; gap: var(--space-md); }
.nav-bar__user { font: var(--type-body-sm); color: var(--ink-muted); }
.nav-bar__logout { margin: 0; }
.container {
width: 100%;
max-width: var(--container-max);
margin: 0 auto;
padding: var(--space-xxl) var(--space-lg);
display: grid;
gap: var(--space-xxl);
flex: 1;
}
.page-head { display: grid; gap: var(--space-xs); }
.heading-1 { font: var(--type-heading-1); letter-spacing: -0.8px; }
.heading-2 { font: var(--type-heading-2); letter-spacing: -0.25px; }
.page-head__sub { margin: 0; color: var(--ink-muted); font: var(--type-body-md); }
/* ---------- Cards ---------- */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: var(--space-md);
}
.tool-card {
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
padding: var(--space-lg);
display: grid;
gap: var(--space-xs);
align-content: start;
}
.tool-card__icon {
width: 40px; height: 40px;
display: grid; place-items: center;
border-radius: var(--rounded-full);
color: #ffffff;
font-size: 15px;
margin-bottom: var(--space-xs);
}
.tool-card__icon--green { background: var(--sticker-green); }
.tool-card__icon--sky { background: var(--sticker-sky); }
.tool-card__icon--purple { background: var(--sticker-purple); color: var(--sticker-purple-deep); }
.tool-card__icon--orange { background: var(--sticker-orange); }
.tool-card__title { font: var(--type-title); }
.tool-card__desc { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); }
.status-pill {
justify-self: start;
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: var(--space-xs);
padding: 4px 10px;
border: 1px solid var(--hairline);
border-radius: var(--rounded-full);
font: var(--type-eyebrow);
color: var(--ink-muted);
}
.status-pill--on { color: var(--sticker-green); border-color: color-mix(in srgb, var(--sticker-green) 40%, var(--hairline)); }
.status-pill--off { color: var(--sticker-red); border-color: color-mix(in srgb, var(--sticker-red) 40%, var(--hairline)); }
.dot { width: 8px; height: 8px; border-radius: var(--rounded-full); display: inline-block; }
.dot--on { background: var(--sticker-green); }
.dot--off { background: var(--sticker-red); }
.dot--faint { background: var(--ink-faint); }
.category-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-full);
font: var(--type-eyebrow);
color: var(--ink-muted);
}
/* ---------- Sections ---------- */
.section { display: grid; gap: var(--space-md); }
.section__head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); }
.empty-state {
background: var(--surface);
border: 1px dashed var(--hairline);
border-radius: var(--rounded-lg);
padding: var(--space-xl);
text-align: center;
}
.empty-state__title { margin: 0 0 var(--space-xs); font: var(--type-title); color: var(--ink); }
.empty-state__body { margin: 0 auto; max-width: 52ch; color: var(--ink-muted); font: var(--type-body-sm); }
/* ---------- Footer ---------- */
.app-foot,
.login-foot {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-lg);
color: var(--ink-faint);
font: var(--type-caption);
}
.app-foot { justify-content: center; border-top: 1px solid var(--hairline); background: var(--surface); }
.login-foot { color: rgba(255, 255, 255, 0.6); }
.app-foot__sep { opacity: 0.5; }
/* ---------- Responsive ---------- */
@media (max-width: 760px) {
.login-band { grid-template-columns: 1fr; gap: var(--space-xl); text-align: left; }
.login-headline { font: var(--type-heading-1); letter-spacing: -0.8px; }
.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; }
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,110 @@
/* ============================================================
Family Home Lab — Design Tokens
Source: DESIGN.md (Notion design language)
Only structural/semantic tokens here. All values map 1:1.
============================================================ */
/* Inter (@fontsource) — self-hosted substitute for NotionInter */
@font-face { font-family: "Inter"; font-style: normal; font-weight: 400; font-display: swap; src: url("/static/fonts/inter-400.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 500; font-display: swap; src: url("/static/fonts/inter-500.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 600; font-display: swap; src: url("/static/fonts/inter-600.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-weight: 700; font-display: swap; src: url("/static/fonts/inter-700.woff2") format("woff2"); }
:root {
/* --- Surface --- */
--canvas-soft: #f6f5f4;
--surface: #ffffff;
--surface-2: #f2f2f1;
--hairline: #e6e6e6;
/* --- Text --- */
--ink: #000000;
--ink-secondary: #31302e;
--ink-muted: #615d59;
--ink-faint: #a39e98;
/* --- Structural accents --- */
--primary: #0075de; /* the ONLY action colour */
--primary-active: #005bab;
--on-primary: #ffffff;
--secondary: #213183; /* deep indigo — login band + greeting only */
/* --- Sticker palette (decorative only) --- */
--sticker-sky: #62aef0; /* chat */
--sticker-purple: #d6b6f6; /* image */
--sticker-pink: #ff64c8; /* video */
--sticker-orange: #dd5b00; /* audio */
--sticker-teal: #2a9d99; /* docs */
--sticker-green: #1aae39; /* status: online/saved */
--sticker-red: #d33a2b; /* status: failed / error (ON_OFF.md decision) */
--sticker-purple-deep: #391c57; /* illustration only */
--sticker-orange-deep: #793400; /* illustration only */
/* --- Typography (Inter, self-hosted via @font-face) --- */
--font-family: "Inter", -apple-system, system-ui, "Segoe UI",
Helvetica, Arial, sans-serif;
/* Font-family is baked into each token below: the CSS `font` shorthand
resets font-family to the UA default (serif) unless a family is written,
and it cannot carry letter-spacing — so weights/sizes ship inline and the
family is appended per token. */
--type-display-1: 700 48px/1.05 var(--font-family);
--type-heading-1: 700 32px/1.1 var(--font-family);
--type-heading-2: 700 22px/1.27 var(--font-family);
--type-title: 600 17px/1.4 var(--font-family);
--type-body-md: 400 15px/1.5 var(--font-family);
--type-body-sm: 400 14px/1.4 var(--font-family);
--type-button: 500 15px/1.4 var(--font-family);
--type-caption: 400 13px/1.43 var(--font-family);
--type-eyebrow: 600 12px/1.33 var(--font-family);
/* --- Layout (base unit 8px) --- */
--space-xxs: 4px;
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 28px;
--space-xxl: 32px;
--container-max: 1200px;
/* --- Elevation --- */
--shadow-level-1: rgba(0, 0, 0, 0.01) 0 0.175px 1px,
rgba(0, 0, 0, 0.02) 0 0.8px 3px,
rgba(0, 0, 0, 0.027) 0 2px 8px,
rgba(0, 0, 0, 0.04) 0 4px 18px;
--shadow-level-2: rgba(0, 0, 0, 0.0122) 0 0.175px 1px,
rgba(0, 0, 0, 0.024) 0 0.8px 3px,
rgba(0, 0, 0, 0.032) 0 2px 8px,
rgba(0, 0, 0, 0.047) 0 4px 18px,
rgba(0, 0, 0, 0.05) 0 23px 52px;
/* --- Shapes --- */
--rounded-xs: 4px;
--rounded-sm: 5px;
--rounded-md: 8px;
--rounded-lg: 12px;
--rounded-full: 9999px;
}
/* Dark mode — flip the semantic tokens only (components use var(--…)). */
[data-theme="dark"] {
--canvas-soft: #141518;
--surface: #1c1e22;
--surface-2: #24262c;
--hairline: #33363d;
--ink: #f5f6f7;
--ink-secondary: #dcdee2;
--ink-muted: #a9adb6;
--ink-faint: #727680;
--primary: #3d8be0;
--primary-active: #5d9ff0;
--on-primary: #ffffff;
--secondary: #10142a;
--shadow-level-1: rgba(0, 0, 0, 0.45) 0 1px 3px;
--shadow-level-2: rgba(0, 0, 0, 0.55) 0 5px 22px;
}
/* Numerals: enable OpenType lnum */
html { font-feature-settings: "lnum" 1; }

View File

@@ -0,0 +1,54 @@
{{define "dashboard.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">
<script src="/static/htmx.min.js" defer></script>
</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>
<form method="post" action="/logout" class="nav-bar__logout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button-utility" type="submit">Sign out</button>
</form>
</div>
</nav>
<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>
</header>
{{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>
<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>
</div>
</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,40 @@
{{define "login.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>Sign in — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="login-page">
<main class="login-band">
<div class="login-band__inner">
<p class="login-eyebrow">Internet Use Control</p>
<h1 class="login-headline">Good to see you,<br>Sam.</h1>
<p class="login-sub">Sign in to manage the kids' devices.</p>
</div>
<section class="auth-card" aria-labelledby="signin-title">
<h2 id="signin-title" class="auth-card__title">Sign in</h2>
{{if .Error}}<p class="auth-error" role="alert">{{.Error}}</p>{{end}}
<form method="post" action="/login" class="auth-form" autocomplete="on">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label class="field">
<span class="field__label">Username</span>
<input class="text-input" type="text" name="username" value="{{.Username}}"
autocomplete="username" autocapitalize="none" spellcheck="false" required>
</label>
<label class="field">
<span class="field__label">Password</span>
<input class="text-input" type="password" name="password"
autocomplete="current-password" required>
</label>
<button class="button-primary" type="submit">Sign in</button>
</form>
</section>
</main>
<footer class="login-foot">Family Home Lab · Control Service</footer>
</body>
</html>{{end}}

View File

@@ -0,0 +1,34 @@
{{define "status.html"}}<section class="card-grid" id="status" hx-get="/partials/status" hx-trigger="every 10s" hx-swap="outerHTML">
<article class="tool-card">
<div class="tool-card__icon tool-card__icon--green" aria-hidden="true">●</div>
<h3 class="tool-card__title">Service</h3>
<p class="tool-card__desc">
{{if .DBHealthy}}Running{{else}}Database error{{end}}
</p>
<span class="status-pill {{if .DBHealthy}}status-pill--on{{else}}status-pill--off{{end}}">
<span class="dot {{if .DBHealthy}}dot--on{{else}}dot--off{{end}}"></span>
{{if .DBHealthy}}online{{else}}offline{{end}}
</span>
</article>
<article class="tool-card">
<div class="tool-card__icon tool-card__icon--sky" aria-hidden="true">▤</div>
<h3 class="tool-card__title">Database</h3>
<p class="tool-card__desc">SQLite · source of truth</p>
<span class="status-pill status-pill--muted">{{if .DBHealthy}}ok{{else}}error{{end}}</span>
</article>
<article class="tool-card">
<div class="tool-card__icon tool-card__icon--purple" aria-hidden="true">⏱</div>
<h3 class="tool-card__title">Uptime</h3>
<p class="tool-card__desc">{{.ServerUp}}</p>
<span class="status-pill status-pill--muted">since boot</span>
</article>
<article class="tool-card">
<div class="tool-card__icon tool-card__icon--orange" aria-hidden="true">⌚</div>
<h3 class="tool-card__title">Server time</h3>
<p class="tool-card__desc">{{.ServerTime}}</p>
<span class="status-pill status-pill--muted">local</span>
</article>
</section>{{end}}