".
+func bearerToken(r *http.Request) (string, bool) {
+ header := r.Header.Get("Authorization")
+ kind, token, found := strings.Cut(header, " ")
+ if !found || !strings.EqualFold(kind, "Bearer") {
+ return "", false
+ }
+ token = strings.TrimSpace(token)
+ return token, token != ""
+}
+
+// decodeOverride reads and bounds the JSON body. When requireBody is false an
+// empty body is the same as {}.
+func decodeOverride(w http.ResponseWriter, r *http.Request, requireBody bool) (overrideRequest, bool) {
+ var req overrideRequest
+ if r.Body == nil {
+ if requireBody {
+ writeAPIError(w, http.StatusBadRequest, "a JSON body is required")
+ return req, false
+ }
+ return req, true
+ }
+ dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, apiMaxBody))
+ err := dec.Decode(&req)
+ if err != nil {
+ if strings.Contains(err.Error(), "EOF") && !requireBody {
+ return req, true
+ }
+ writeAPIError(w, http.StatusBadRequest, "invalid JSON body")
+ return req, false
+ }
+ // A second JSON value in the body is a mistake.
+ if dec.Decode(&struct{}{}) == nil {
+ writeAPIError(w, http.StatusBadRequest, "body must contain one JSON object")
+ return req, false
+ }
+ return req, true
+}
+
+// decodeEmpty accepts an empty or absent body and rejects anything malformed.
+func decodeEmpty(w http.ResponseWriter, r *http.Request) bool {
+ if r.Body == nil || r.ContentLength == 0 {
+ return true
+ }
+ _, ok := decodeOverride(w, r, false)
+ return ok
+}
+
+// resolveUntil turns a request into an absolute end time. Exactly one of
+// minutes or until may be set. A nil result with no error means "no end time"
+// (only meaningful for a lock).
+func resolveUntil(req overrideRequest, maxMinutes int) (*time.Time, error) {
+ if req.Minutes < 0 {
+ return nil, errors.New("minutes must not be negative")
+ }
+ if req.Until != "" && req.Minutes > 0 {
+ return nil, errors.New("set either minutes or until, not both")
+ }
+ if req.Until != "" {
+ t, err := time.Parse(time.RFC3339, strings.TrimSpace(req.Until))
+ if err != nil {
+ return nil, errors.New("until must be an RFC3339 instant, for example 2026-10-13T21:00:00+11:00")
+ }
+ t = t.UTC()
+ if !t.After(time.Now()) {
+ return nil, errors.New("until must be in the future")
+ }
+ return &t, nil
+ }
+ if req.Minutes > maxMinutes {
+ return nil, errors.New("minutes is too large")
+ }
+ if req.Minutes > 0 {
+ t := time.Now().Add(time.Duration(req.Minutes) * time.Minute).UTC()
+ return &t, nil
+ }
+ return nil, nil
+}
+
+// apiDeviceError maps a store error to an HTTP status.
+func (s *Server) apiDeviceError(w http.ResponseWriter, err error) {
+ if errors.Is(err, store.ErrNotFound) {
+ writeAPIError(w, http.StatusNotFound, "device not found")
+ return
+ }
+ s.log.Error("admin api device", "err", err)
+ writeAPIError(w, http.StatusBadRequest, err.Error())
+}
+
+func writeAPIJSON(w http.ResponseWriter, code int, body apiResponse) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(body)
+}
+
+func writeAPIError(w http.ResponseWriter, code int, message string) {
+ writeAPIJSON(w, code, apiResponse{OK: false, Error: message})
+}
+
+// clientIP is the direct peer address. No proxy header is trusted: the service
+// is reached through Caddy, but X-Forwarded-For is caller-controlled.
+func clientIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+// ---------- keycode throttle ----------
+
+// keycodeThrottle bounds brute force on the shared code: five failures from one
+// address freeze that address for five minutes. It is in memory on purpose —
+// the Control Service is a single process, and a restart clearing it is an
+// acceptable trade for keeping the store schema small.
+const (
+ keycodeMaxFailures = 5
+ keycodeFreeze = 5 * time.Minute
+)
+
+type keycodeAttempts struct {
+ failures int
+ blocked time.Time
+}
+
+func (s *Server) keycodeThrottled(remote string) bool {
+ s.keycodeMu.Lock()
+ defer s.keycodeMu.Unlock()
+ a, ok := s.keycode[remote]
+ if !ok {
+ return false
+ }
+ if time.Now().Before(a.blocked) {
+ return true
+ }
+ if !a.blocked.IsZero() {
+ delete(s.keycode, remote) // freeze over; start fresh
+ }
+ return false
+}
+
+func (s *Server) noteKeycodeFailure(remote string) {
+ s.keycodeMu.Lock()
+ defer s.keycodeMu.Unlock()
+ a := s.keycode[remote]
+ a.failures++
+ if a.failures >= keycodeMaxFailures {
+ a.blocked = time.Now().Add(keycodeFreeze)
+ a.failures = 0
+ }
+ s.keycode[remote] = a
+}
+
+func (s *Server) noteKeycodeSuccess(remote string) {
+ s.keycodeMu.Lock()
+ defer s.keycodeMu.Unlock()
+ delete(s.keycode, remote)
+}
+
+// keycodeConfigured reports whether any keycode can be verified.
+func (s *Server) keycodeConfigured(ctx context.Context) bool {
+ _, err := s.store.KeycodeHash(ctx)
+ return err == nil
+}
diff --git a/internal/web/admin_api_test.go b/internal/web/admin_api_test.go
new file mode 100644
index 0000000..bf443af
--- /dev/null
+++ b/internal/web/admin_api_test.go
@@ -0,0 +1,337 @@
+package web
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+
+ "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"
+)
+
+// fakePublisher records the retained config publishes an API call makes, so a
+// test can prove a state change reaches the broker before the response.
+type fakePublisher struct {
+ mu sync.Mutex
+ topics []string
+}
+
+func (f *fakePublisher) Broker() string { return "fake" }
+func (f *fakePublisher) Connected() bool { return true }
+func (f *fakePublisher) Stats() (int64, int64, time.Time, string) {
+ return 0, 0, time.Time{}, ""
+}
+func (f *fakePublisher) PublishDeviceConfig(_ context.Context, dev store.Device) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.topics = append(f.topics, dev.Topic)
+ return nil
+}
+func (f *fakePublisher) PublishAll(context.Context) (int, error) { return 0, nil }
+
+func (f *fakePublisher) published() []string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.topics...)
+}
+
+type apiTestApp struct {
+ handler http.Handler
+ store *store.Store
+ pub *fakePublisher
+ token string
+}
+
+func newAPITestApp(t *testing.T, token, keycode string) *apiTestApp {
+ 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(context.Background()); 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)
+ }
+ pub := &fakePublisher{}
+ cfg := &config.Config{
+ AdminUsername: "sam",
+ Version: "test",
+ AppEnv: "test",
+ DBPath: ":memory:",
+ AdminAPIToken: token,
+ ScheduleLocation: time.UTC,
+ }
+ srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)), pub)
+ if err != nil {
+ t.Fatalf("web.New: %v", err)
+ }
+ if keycode != "" {
+ hash, err := bcrypt.GenerateFromPassword([]byte(keycode), bcrypt.MinCost)
+ if err != nil {
+ t.Fatalf("bcrypt: %v", err)
+ }
+ if err := st.SetKeycodeHash(context.Background(), string(hash), "test"); err != nil {
+ t.Fatalf("SetKeycodeHash: %v", err)
+ }
+ }
+ return &apiTestApp{handler: srv.Handler(), store: st, pub: pub, token: token}
+}
+
+func (a *apiTestApp) do(t *testing.T, method, path, body, token string) *httptest.ResponseRecorder {
+ t.Helper()
+ var reader io.Reader
+ if body != "" {
+ reader = strings.NewReader(body)
+ }
+ req := httptest.NewRequest(method, path, reader)
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ a.handler.ServeHTTP(rec, req)
+ return rec
+}
+
+func decodeAPIResponse(t *testing.T, rec *httptest.ResponseRecorder) apiResponse {
+ t.Helper()
+ var resp apiResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response %q: %v", rec.Body.String(), err)
+ }
+ return resp
+}
+
+func mustCreateDevice(t *testing.T, st *store.Store, name string) store.Device {
+ t.Helper()
+ dev, err := st.CreateDevice(context.Background(), name, "Harry", "android", "test")
+ if err != nil {
+ t.Fatalf("CreateDevice: %v", err)
+ }
+ return *dev
+}
+
+func TestAdminAPIAuth(t *testing.T) {
+ app := newAPITestApp(t, "s3cret", "")
+ dev := mustCreateDevice(t, app.store, "Harry Phone")
+
+ if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", ""); rec.Code != http.StatusUnauthorized {
+ t.Errorf("no token = %d, want 401", rec.Code)
+ }
+ if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "wrong"); rec.Code != http.StatusUnauthorized {
+ t.Errorf("wrong token = %d, want 401", rec.Code)
+ }
+ if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "s3cret"); rec.Code != http.StatusOK {
+ t.Errorf("good token = %d, want 200", rec.Code)
+ }
+
+ // The rejection is audited.
+ audit, err := app.store.ListAudit(context.Background(), 10)
+ if err != nil {
+ t.Fatalf("ListAudit: %v", err)
+ }
+ var rejected int
+ for _, e := range audit {
+ if e.Action == "admin.token_rejected" {
+ rejected++
+ }
+ }
+ if rejected != 2 {
+ t.Errorf("admin.token_rejected = %d, want 2", rejected)
+ }
+
+ // The token is separate from the session login: a session cookie does not
+ // authorise the API and the API token does not open the dashboard.
+ if rec := app.do(t, http.MethodGet, "/", "", ""); rec.Code != http.StatusSeeOther {
+ t.Errorf("dashboard without session = %d, want 303", rec.Code)
+ }
+ _ = dev
+}
+
+func TestAdminAPIUnconfigured(t *testing.T) {
+ app := newAPITestApp(t, "", "")
+ if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "anything"); rec.Code != http.StatusServiceUnavailable {
+ t.Errorf("unconfigured admin API = %d, want 503", rec.Code)
+ }
+}
+
+func TestAdminAPILockUnlockGrant(t *testing.T) {
+ app := newAPITestApp(t, "s3cret", "")
+ dev := mustCreateDevice(t, app.store, "Harry Phone")
+
+ // Lock for 30 minutes.
+ rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/1/lock", `{"minutes":30}`, "s3cret")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("lock = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ resp := decodeAPIResponse(t, rec)
+ if !resp.OK || resp.Action != "lock" || resp.Device == nil {
+ t.Fatalf("lock response = %+v", resp)
+ }
+ if !resp.Device.Locked || resp.Device.Reason != "manual" || resp.Device.Until == nil {
+ t.Errorf("locked device = %+v", resp.Device)
+ }
+ if got := app.pub.published(); len(got) != 1 || got[0] != dev.Topic {
+ t.Errorf("published = %v, want the device config topic", got)
+ }
+
+ // Grant time (unlocked, with an end).
+ rec = app.do(t, http.MethodPost, "/api/v1/admin/devices/1/grant", `{"minutes":15}`, "s3cret")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("grant = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ resp = decodeAPIResponse(t, rec)
+ if resp.Device == nil || resp.Device.Locked || resp.Device.Reason != "grant" || resp.Device.GrantUntil == nil {
+ t.Fatalf("granted device = %+v", resp.Device)
+ }
+
+ // Unlock clears the grant.
+ rec = app.do(t, http.MethodPost, "/api/v1/admin/devices/1/unlock", "", "s3cret")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unlock = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ resp = decodeAPIResponse(t, rec)
+ if resp.Device == nil || resp.Device.Locked || resp.Device.GrantUntil != nil {
+ t.Fatalf("unlocked device = %+v", resp.Device)
+ }
+
+ // Query one device and the list.
+ if rec := app.do(t, http.MethodGet, "/api/v1/admin/devices/1", "", "s3cret"); rec.Code != http.StatusOK {
+ t.Errorf("state = %d, want 200", rec.Code)
+ }
+ rec = app.do(t, http.MethodGet, "/api/v1/admin/devices", "", "s3cret")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("list = %d, want 200", rec.Code)
+ }
+ if resp = decodeAPIResponse(t, rec); len(resp.Devices) != 1 || resp.Devices[0].Name != "Harry Phone" {
+ t.Errorf("device list = %+v", resp.Devices)
+ }
+
+ // Every mutation is in the override history and the audit log.
+ overrides, err := app.store.ListOverrides(context.Background(), 1, 10)
+ if err != nil {
+ t.Fatalf("ListOverrides: %v", err)
+ }
+ if len(overrides) != 3 {
+ t.Errorf("overrides = %d, want 3 (lock, grant, unlock)", len(overrides))
+ }
+ audit, _ := app.store.ListAudit(context.Background(), 20)
+ var stateChanges int
+ for _, e := range audit {
+ switch e.Action {
+ case "state.locked", "override.granted", "state.unlocked":
+ stateChanges++
+ }
+ }
+ if stateChanges != 3 {
+ t.Errorf("audited state changes = %d, want 3", stateChanges)
+ }
+
+ // A publish happens for every mutation.
+ if got := app.pub.published(); len(got) != 3 {
+ t.Errorf("publishes = %v, want 3", got)
+ }
+}
+
+func TestAdminAPIBadRequests(t *testing.T) {
+ app := newAPITestApp(t, "s3cret", "")
+ mustCreateDevice(t, app.store, "Harry Phone")
+
+ cases := []struct {
+ name string
+ body string
+ code int
+ }{
+ {"unknown field JSON is rejected", `not json`, http.StatusBadRequest},
+ {"negative minutes", `{"minutes":-5}`, http.StatusBadRequest},
+ {"past until", `{"until":"2000-01-01T00:00:00Z"}`, http.StatusBadRequest},
+ {"both minutes and until", `{"minutes":5,"until":"2999-01-01T00:00:00Z"}`, http.StatusBadRequest},
+ {"grant with no time", `{}`, http.StatusBadRequest},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/1/grant", tc.body, "s3cret")
+ if rec.Code != tc.code {
+ t.Errorf("grant %q = %d, want %d; body=%s", tc.body, rec.Code, tc.code, rec.Body.String())
+ }
+ })
+ }
+
+ if rec := app.do(t, http.MethodPost, "/api/v1/admin/devices/999/lock", `{}`, "s3cret"); rec.Code != http.StatusNotFound {
+ t.Errorf("lock unknown device = %d, want 404", rec.Code)
+ }
+}
+
+func TestDeviceKeycodeOverride(t *testing.T) {
+ app := newAPITestApp(t, "s3cret", "246810")
+ mustCreateDevice(t, app.store, "Harry Phone")
+
+ // A wrong code is refused and audited.
+ rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"000000","minutes":10}`, "")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("wrong keycode = %d, want 401; body=%s", rec.Code, rec.Body.String())
+ }
+
+ // The right code grants time without the admin token.
+ rec = app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810","minutes":10}`, "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("keycode = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ resp := decodeAPIResponse(t, rec)
+ if !resp.OK || resp.Action != "keycode" || resp.Device == nil || resp.Device.Reason != "grant" {
+ t.Fatalf("keycode response = %+v", resp)
+ }
+
+ // A keycode with no duration still grants a short, fixed exemption.
+ rec = app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810"}`, "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("keycode without duration = %d, want 200", rec.Code)
+ }
+ if resp = decodeAPIResponse(t, rec); resp.Device == nil || resp.Device.GrantUntil == nil {
+ t.Fatalf("default grant = %+v", resp.Device)
+ }
+}
+
+func TestDeviceKeycodeThrottle(t *testing.T) {
+ app := newAPITestApp(t, "s3cret", "246810")
+ mustCreateDevice(t, app.store, "Harry Phone")
+
+ for i := 0; i < 5; i++ {
+ rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"wrong"}`, "")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("attempt %d = %d, want 401", i+1, rec.Code)
+ }
+ }
+ // The sixth attempt is throttled even with the right code.
+ rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"246810","minutes":5}`, "")
+ if rec.Code != http.StatusTooManyRequests {
+ t.Fatalf("after five failures = %d, want 429; body=%s", rec.Code, rec.Body.String())
+ }
+}
+
+func TestDeviceKeycodeUnconfigured(t *testing.T) {
+ app := newAPITestApp(t, "s3cret", "")
+ mustCreateDevice(t, app.store, "Harry Phone")
+ rec := app.do(t, http.MethodPost, "/api/v1/device/override", `{"device_id":1,"keycode":"1234"}`, "")
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("unconfigured keycode = %d, want 503", rec.Code)
+ }
+}
diff --git a/internal/web/devices.go b/internal/web/devices.go
index 1d441c0..d311716 100644
--- a/internal/web/devices.go
+++ b/internal/web/devices.go
@@ -29,6 +29,7 @@ type deviceView struct {
Locked bool
ManualLocked bool
Scheduled bool
+ Granted bool
Until *time.Time
UntilLocal string
TimeLeft string
@@ -81,9 +82,14 @@ func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState,
v.ReasonLabel = "by schedule"
case "manual":
v.ReasonLabel = "manually"
+ case "grant":
+ v.Granted = true
+ v.ReasonLabel = "time granted"
}
if eff.Locked {
v.StateLabel = "Locked"
+ } else if v.Granted {
+ v.StateLabel = "Granted"
} else {
v.StateLabel = "Unlocked"
}
@@ -92,6 +98,12 @@ func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState,
v.UntilLocal = local.Format("Mon 15:04")
v.TimeLeft = timeLeft(*eff.Until)
}
+ if eff.GrantUntil != nil {
+ v.Until = eff.GrantUntil
+ local := eff.GrantUntil.In(loc)
+ v.UntilLocal = local.Format("Mon 15:04")
+ v.TimeLeft = timeLeft(*eff.GrantUntil)
+ }
if d.LastSeen != nil {
v.LastSeen = d.LastSeen.In(loc).Format("Mon 02 Jan 15:04")
v.HasLastSeen = true
@@ -167,6 +179,18 @@ func auditLabel(action string) string {
return "Unlocked"
case "state.expired":
return "Lock expired"
+ case "override.granted":
+ return "Time granted"
+ case "override.keycode":
+ return "Keycode override"
+ case "override.keycode_rejected":
+ return "Keycode rejected"
+ case "override.keycode_throttled":
+ return "Keycode throttled"
+ case "keycode.updated":
+ return "Keycode changed"
+ case "admin.token_rejected":
+ return "Admin API rejected"
case "schedule.updated":
return "Schedule saved"
case "list.added":
diff --git a/internal/web/server.go b/internal/web/server.go
index ea3ba09..9c1b651 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -12,6 +12,7 @@ import (
"log/slog"
"net/http"
"strings"
+ "sync"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth"
@@ -33,6 +34,12 @@ type Server struct {
tmpl *template.Template
log *slog.Logger
pub Publisher
+
+ // keycodeMu guards the in-memory throttle for the device keycode endpoint
+ // (admin_api.go). Failures are remembered per client address, so a wrong
+ // code cannot be tried without limit.
+ keycodeMu sync.Mutex
+ keycode map[string]keycodeAttempts
}
// Publisher pushes device config to the broker and reports the link's health.
@@ -60,7 +67,15 @@ func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger
if err != nil {
return nil, fmt.Errorf("parse templates: %w", err)
}
- return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log, pub: pub}, nil
+ return &Server{
+ cfg: cfg,
+ store: st,
+ auth: am,
+ tmpl: tmpl,
+ log: log,
+ pub: pub,
+ keycode: make(map[string]keycodeAttempts),
+ }, nil
}
// publishConfig pushes one device's desired state to the broker, retained. A
@@ -103,6 +118,19 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /devices/{id}/lists/{entryID}/delete", s.requireAuth(s.handleListDelete))
mux.HandleFunc("GET /devices/{id}/usage", s.requireAuth(s.handleDeviceUsage))
+ // Versioned admin API (Phase 8). Bearer-token auth, separate from the
+ // session login. The Kotlin admin app calls these directly (AGENTS.md §8,
+ // §11 Phase 8).
+ api := "/api/" + apiVersion
+ mux.HandleFunc("GET "+api+"/admin/devices", s.requireAPIToken(s.handleAPIListDevices))
+ mux.HandleFunc("GET "+api+"/admin/devices/{id}", s.requireAPIToken(s.handleAPIState))
+ mux.HandleFunc("POST "+api+"/admin/devices/{id}/lock", s.requireAPIToken(s.handleAPILock))
+ mux.HandleFunc("POST "+api+"/admin/devices/{id}/unlock", s.requireAPIToken(s.handleAPIUnlock))
+ mux.HandleFunc("POST "+api+"/admin/devices/{id}/grant", s.requireAPIToken(s.handleAPIGrant))
+ // The on-device keycode path is not behind the admin token: a device does
+ // not hold that secret. It is throttled and audited instead (admin_api.go).
+ mux.HandleFunc("POST "+api+"/device/override", s.handleDeviceOverride)
+
staticSub, err := fs.Sub(staticFS, "static")
if err != nil {
panic("web: embedded static dir missing: " + err.Error())
diff --git a/internal/web/templates/partials/device-cards.html b/internal/web/templates/partials/device-cards.html
index 15b549d..cfeb220 100644
--- a/internal/web/templates/partials/device-cards.html
+++ b/internal/web/templates/partials/device-cards.html
@@ -12,9 +12,9 @@
- {{if .Locked}}Blocked{{if .ReasonLabel}} {{.ReasonLabel}}{{end}}{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}}
+ {{if .Locked}}Blocked{{if .ReasonLabel}} {{.ReasonLabel}}{{end}}{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else if .Granted}}Time granted{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}}
- {{if .Until}}Lifts {{.UntilLocal}}
{{end}}
+ {{if .Until}}{{if .Granted}}Grant ends{{else}}Lifts{{end}} {{.UntilLocal}}
{{end}}