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

+11 -86
View File
@@ -19,48 +19,7 @@ function showAddDeviceModal(): void {
function hideAddDeviceModal(): void {
const modal = document.getElementById("add-device-modal");
const form = document.getElementById("add-device-form");
if (modal) modal.classList.add("hidden");
if (form && form instanceof HTMLFormElement) form.reset();
}
async function handleAddDevice(event: Event): Promise<void> {
event.preventDefault();
// cookie auth carries the call; a null bearer must not strand it
const deviceNameInput = document.getElementById("device-name") as HTMLInputElement;
const deviceTypeInput = document.getElementById("device-type") as HTMLSelectElement;
const deviceIdentifierInput = document.getElementById("device-identifier") as HTMLInputElement;
if (!deviceNameInput || !deviceTypeInput || !deviceIdentifierInput) return;
const data = {
device_name: deviceNameInput.value,
device_type: deviceTypeInput.value,
device_identifier: deviceIdentifierInput.value,
};
try {
const response = await fetch("/api/devices/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (response.ok) {
showToast("Device registered! Check your device for sync instructions.", "success");
hideAddDeviceModal();
window.location.reload();
} else {
showToast("Failed to register device", "error");
}
} catch (error) {
console.error("Failed to register device", error);
showToast("Failed to register device", "error");
}
}
function showDeviceSettings(deviceId: string): void {
@@ -483,48 +442,6 @@ async function handleRevokeDevice(): Promise<void> {
}
}
async function approveDevice(registrationId: string): Promise<void> {
// cookie auth carries the call; a null bearer must not strand it
try {
const response = await fetch(`/api/devices/approve/${registrationId}`, {
method: "GET",
headers: authHeaders(),
});
if (response.ok) {
showToast("Device approved successfully", "success");
window.location.reload();
} else {
showToast("Failed to approve device", "error");
}
} catch (error) {
console.error("Failed to approve device", error);
showToast("Failed to approve device", "error");
}
}
async function rejectDevice(registrationId: string): Promise<void> {
// cookie auth carries the call; a null bearer must not strand it
try {
const response = await fetch(`/api/devices/reject/${registrationId}`, {
method: "POST",
headers: authHeaders(),
});
if (response.ok) {
showToast("Device registration rejected", "info");
window.location.reload();
} else {
showToast("Failed to reject device", "error");
}
} catch (error) {
console.error("Failed to reject device", error);
showToast("Failed to reject device", "error");
}
}
function setupEventDelegation(): void {
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
@@ -545,10 +462,20 @@ function setupEventDelegation(): void {
deleteMapping(button.dataset.mappingId || "");
}
});
// Pairing section: htmx only swaps 2xx bodies, so claim/lookup failures
// (wrong code, rate limit, expired) would otherwise fail silently.
// Content-level answers come from the templ partial; this toast covers
// the non-swap error path.
document.body.addEventListener("htmx:responseError", (e) => {
const detail = (e as CustomEvent<{ requestConfig?: { elt?: Element } }>).detail;
if (detail?.requestConfig?.elt?.closest("#pairing-section")) {
showToast("Pairing failed — check the code and try again.", "error");
}
});
}
Alpine.data("devices", () => ({
approveDevice,
copyToClipboard,
copyFromId(id: string, label: string) {
const el = document.getElementById(id) as HTMLInputElement | null;
@@ -557,7 +484,6 @@ Alpine.data("devices", () => ({
deleteMapping,
editMapping,
getDeviceIcon,
handleAddDevice,
handleRevokeDevice,
handleSaveDeviceSettings,
handleSaveMapping,
@@ -567,7 +493,6 @@ Alpine.data("devices", () => ({
hideShelfMappingsModal,
loadCollections,
loadShelfMappings,
rejectDevice,
setupEventDelegation,
showAddDeviceModal,
showAddMappingModal,