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:
1 parent
8fbacd9f46
commit
fef79b1836
9 files changed
+253
-313
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user