feat(devices): pair-a-device card replaces the pending registration list

The Devices page's Pending Device Registrations section (Approve/Reject
cards broadcast to every account) is replaced by an always-visible Pair
a Device field: type the 6-digit code shown on the registering e-reader,
a templ-rendered confirm card shows what will be bound, and confirming
claims the device to the signed-in account.

Rendering ownership (AGENTS.md): the confirm card and every lookup
outcome are templ partials (PairingLookupResult) swapped by htmx from a
new SSR route POST /devices/pairing/lookup — the server decides what UI
looks like, no JSON-built HTML. Errors render as content because htmx
only swaps 2xx bodies (the route is web-only; the JSON API keeps real
status codes), and one delegated htmx:responseError listener in
device-management.ts toasts the non-swap failure path.

The claim confirm button posts straight to /api/devices/claim; success
comes back as HX-Refresh (same pattern as token regeneration), so the
page reloads into the paired state. No new TS module is needed — plain
htmx attributes drive the whole flow.

Ride-alongs: the Add New Device modal loses the device-type select and
the kobo manual form (its post-register UX depended on the broadcast
list and silently produced an unclaimable registration; native Kobo
sync is still server-side-only and will get its own pairing UX when it
lands) — the modal is now a single Connect-a-KOReader-Device guide with
the pairing steps. PendingRegistrationData, convertPending, and the
pending plumbing in the page handler are gone with the section.
ui-controls.md glossary and tests/ui COVERAGE.md updated in the same
commit as the control rename, per the drift gates.
This commit is contained in:
John O'Keefe committed 2026-11-02 16:11:15 -05:00
1 parent 8fbacd9f46
commit fef79b1836
9 files changed
+253 -313

No files matched your search

+31 -26
View File
@@ -1152,7 +1152,6 @@ func registerFrontendRoutes(cfg *Config) {
var errorMsg string
var devices []handlers.DeviceInfo
var pendingList []templates.PendingRegistrationData
devices, err = cfg.DeviceHandler.GetDevicesData(c)
if err != nil {
@@ -1161,22 +1160,40 @@ func registerFrontendRoutes(cfg *Config) {
errorMsg = "Error loading devices"
}
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
if err != nil {
log.Printf("GetPendingRegistrationsData failed: %v", err)
pendingList = []templates.PendingRegistrationData{}
if errorMsg == "" {
errorMsg = "Error loading pending registrations"
}
} else {
pendingList = convertPending(pendingMaps)
}
// Get base URL from database config with fallback to config/env var
baseURL := cfg.getBaseURL(c.Request().Context())
var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
err = templates.Devices(user, devices, errorMsg, baseURL).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Pairing-code lookup partial: renders the confirm card (or the
// reason nothing matched) into #pairing-lookup-result. Errors render
// as content — htmx only swaps 2xx bodies, and the pairing section is
// a web-only surface (the JSON API route keeps real status codes).
frontendProtected.POST("/devices/pairing/lookup", func(c *echo.Context) error {
userID, ok := c.Get("user_id").(string)
if !ok {
return c.HTML(http.StatusUnauthorized, "")
}
status := "found"
var deviceName, deviceType, pairingCode string
registration, err := handlers.ResolvePairingCode(userID, c.RealIP(), c.FormValue("pairing_code"))
if err != nil {
status = handlers.PairingErrorStatus(err)
} else {
deviceName = registration.DeviceName
deviceType = registration.DeviceType
pairingCode = registration.PairingCode
}
var buf bytes.Buffer
err = templates.PairingLookupResult(deviceName, deviceType, pairingCode, status).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -1834,7 +1851,6 @@ func registerFrontendRoutes(cfg *Config) {
}
var errorMsg string
var devices []handlers.DeviceInfo
var pendingList []templates.PendingRegistrationData
devices, err = cfg.DeviceHandler.GetDevicesData(c)
if err != nil {
@@ -1843,22 +1859,11 @@ func registerFrontendRoutes(cfg *Config) {
errorMsg = "Error loading devices"
}
pendingMaps, err := cfg.DeviceHandler.GetPendingRegistrationsData(c)
if err != nil {
log.Printf("GetPendingRegistrationsData failed: %v", err)
pendingList = []templates.PendingRegistrationData{}
if errorMsg == "" {
errorMsg = "Error loading pending registrations"
}
} else {
pendingList = convertPending(pendingMaps)
}
// Get base URL from database config with fallback to config/env var
baseURL := cfg.getBaseURL(c.Request().Context())
var buf bytes.Buffer
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
err = templates.Devices(user, devices, errorMsg, baseURL).Render(c.Request().Context(), &buf)
if err != nil {
return err
}