diff --git a/IMPLEMENTATION_EXACT.md b/IMPLEMENTATION_EXACT.md index 0ab8801..fe1fba8 100644 --- a/IMPLEMENTATION_EXACT.md +++ b/IMPLEMENTATION_EXACT.md @@ -11,15 +11,12 @@ ## Table of Contents 1. [Phase 1: Enhanced Authentication (Week 1)](#phase-1-enhanced-authentication) - - Database Query Addition - - Middleware Enhancement - - Router Updates - - Backend Handler Addition - - Frontend Template Updates - - Bruno API Tests + - Database Query Addition + - Middleware Enhancement + - Router Updates + - Backend Handler Addition + - Frontend Template Updates 2. [Phase 2: Kobo Integration (Week 1-2)](#phase-2-kobo-integration) - - Documentation Updates - - Test Updates 3. [Phase 3: OPDS Security (Week 2-3)](#phase-3-opds-security) - Router Enhancement - Bruno API Tests @@ -281,7 +278,7 @@ koboSync.POST("/sync-from-server", cfg.DeviceAuthMiddleware.Authenticate(koboHan **File**: `internal/router/device.go` -**Location**: After device registration routes (around line 50) +**Location**: After device registration routes (around line 25) **Current Implementation**: Need to check what device routes exist @@ -294,23 +291,16 @@ koboSync.POST("/sync-from-server", cfg.DeviceAuthMiddleware.Authenticate(koboHan devices.PUT("/:id/regenerate-token", jwtMiddleware, h.RegenerateDeviceToken) ``` -**Complete Context** (assuming placement after device registration routes): - + **Complete Context** (token regeneration route added to existing device routes): ```go -// Device registration endpoints -devices.POST("/register", h.InitiateRegistration) -devices.POST("/approve/:registration_id", jwtMiddleware, h.ApproveDevice) -devices.POST("/reject/:registration_id", jwtMiddleware, h.RejectDevice) -devices.GET("/pending", jwtMiddleware, h.ListPendingRegistrations) +// Existing device management routes (unchanged) +devices.GET("", jwtMiddleware, cfg.DeviceHandler.ListDevices) +devices.GET("/:id", jwtMiddleware, cfg.DeviceHandler.GetDevice) +devices.PUT("/:id", jwtMiddleware, cfg.DeviceHandler.UpdateDevice) +devices.DELETE("/:id", jwtMiddleware, cfg.DeviceHandler.DeleteDevice) -// Device management endpoints -devices.GET("", jwtMiddleware, h.ListDevices) -devices.GET("/:id", jwtMiddleware, h.GetDevice) -devices.PUT("/:id", jwtMiddleware, h.UpdateDevice) -devices.DELETE("/:id", jwtMiddleware, h.DeleteDevice) - -// Token regeneration endpoint (JWT authentication required) -devices.PUT("/:id/regenerate-token", jwtMiddleware, h.RegenerateDeviceToken) +// NEW: Token regeneration endpoint (JWT authentication required) +devices.PUT("/:id/regenerate-token", jwtMiddleware, cfg.DeviceHandler.RegenerateDeviceToken) ``` **Verification**: Run `go build ./internal/router` @@ -517,13 +507,16 @@ deviceList[i] = DeviceInfo{ **File**: `templates/devices.templ` -**Location**: Lines 46-98 (device card in grid) +**Location**: Template function signature (line 5) and device card (lines 46-98) -**Current Implementation**: Device card shows device info and settings buttons +**Required Changes**: -**Required Addition**: Add buttons for copy sync URL and regenerate token +1. **Update template signature** to accept baseURL parameter (line 5): +```templ +templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []PendingRegistrationData, baseURL string) { +``` -**REPLACE DEVICE CARD CONTENT** (lines 46-98) with: +2. **REPLACE DEVICE CARD CONTENT** (lines 46-98) with: ```templ for _, device := range devices { @@ -598,12 +591,12 @@ for _, device := range devices { type="text" id="sync-url-{ device.ID }" readonly - value="{ fmt.Sprintf("http://YOUR_IP:8765/api/sync/kobo/%s", device.AuthToken) }" + value="{ fmt.Sprintf("%s/api/sync/kobo/%s", baseURL, device.AuthToken) }" class="flex-1 px-3 py-2 text-xs rounded border" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" /> + +

Paste this URL into Kobo's api_endpoint setting

+ + } + + if device.DeviceType == "koreader" { + +
+ +
+ + +
+

Enter this token in the KOReader plugin settings

+
+ } + + + +

⚠️ Old token will immediately stop working

+ } diff --git a/templates/devices_templ.go b/templates/devices_templ.go index ab69003..b553ca1 100644 --- a/templates/devices_templ.go +++ b/templates/devices_templ.go @@ -10,7 +10,7 @@ import templruntime "github.com/a-h/templ/runtime" import "bookhoard/internal/handlers" -func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []PendingRegistrationData) templ.Component { +func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []PendingRegistrationData, baseURL string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -31,7 +31,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Device Management - Bookhoard") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Device Management - Bookhoard") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -91,7 +91,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 71, Col: 102} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 102} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -104,13 +104,13 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 89} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 73, Col: 89} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Sync Status ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Sync Status ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -137,7 +137,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(device.LastSync.Format("2006-01-02 15:04")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 85, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 86, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -165,7 +165,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(device.LastSeen.Format("2006-01-02 15:04")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 93, Col: 97} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 94, Col: 97} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -181,76 +181,118 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

DEVICE SYNC CONFIGURATION

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if device.DeviceType == "kobo" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

Paste this URL into Kobo's api_endpoint setting

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if device.DeviceType == "koreader" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Enter this token in the KOReader plugin settings

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

⚠️ Old token will immediately stop working

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(pendingRegistrations) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

Pending Device Registrations

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "

Pending Device Registrations

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, reg := range pendingRegistrations { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 112, Col: 87} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 114, Col: 27} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " - Expires in ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt) + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 114, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 178, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 180, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " - Expires in ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 180, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "

Sync Queue

Add New Device

Enter your device's unique identifier

Device Settings

Allow this device to sync reading progress

Automatically sync changes

Collection View Settings

Configure how collections are displayed on this device

Display book covers in collection views

Display progress indicators for books

Collection to Shelf Mappings

Loading mappings...

Add Collection Mapping

Name of the shelf on this device

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

Sync Queue

Add New Device

Enter your device's unique identifier

Device Settings

Allow this device to sync reading progress

Automatically sync changes

Collection View Settings

Configure how collections are displayed on this device

Display book covers in collection views

Display progress indicators for books

Collection to Shelf Mappings

Loading mappings...

Add Collection Mapping

Name of the shelf on this device

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/device-management.ts b/web/src/device-management.ts new file mode 100644 index 0000000..0599c1c --- /dev/null +++ b/web/src/device-management.ts @@ -0,0 +1,97 @@ +// Device Management - Token copy and regeneration +// Procedural style with proper types (no OOP) + +interface RegenerateTokenResponse { + message: string; + auth_token: string; + device: { + id: string; + device_name: string; + device_type: string; + auth_token: string; + sync_enabled: boolean; + auto_sync: boolean; + sync_frequency_minutes: number; + }; + sync_urls?: { + sync_url?: string; + markup?: string; + bookmark?: string; + init?: string; + progress?: string; + metadata?: string; + bookmarks?: string; + }; +} + +// Copy sync URL or auth token to clipboard +function copyToClipboard(text: string, label: string): void { + navigator.clipboard.writeText(text) + .then(() => { + const toast = (window as any).showToast; + if (toast) { + toast.success(`${label} copied to clipboard`); + } + }) + .catch((err: unknown) => { + console.error('Failed to copy:', err); + const toast = (window as any).showToast; + if (toast) { + toast.error('Failed to copy to clipboard'); + } + }); +} + +// Regenerate device token with confirmation +function regenerateDeviceToken(deviceId: string, event: Event): void { + const confirmation = '⚠️ This will revoke current token and generate a new one.\n\n' + + 'The old token will immediately stop working.\n\n' + + 'You will need to update your device configuration with new token.\n\n' + + 'Continue?'; + + if (!confirm(confirmation)) { + return; + } + + const btn = event.target as HTMLButtonElement; + const originalText = btn.innerHTML; + btn.disabled = true; + btn.innerHTML = '🔄 Regenerating...'; + + fetch(`/api/devices/${deviceId}/regenerate-token`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + } + }) + .then((response: Response) => { + if (!response.ok) { + throw new Error('Failed to regenerate token'); + } + return response.json() as Promise; + }) + .then((_data: RegenerateTokenResponse) => { + const toast = (window as any).showToast; + if (toast) { + toast.success('Token regenerated successfully - update your device config'); + } + // Reload page to show new token + setTimeout(() => location.reload(), 1500); + }) + .catch((error: unknown) => { + console.error('Error:', error); + const toast = (window as any).showToast; + if (toast) { + toast.error('Failed to regenerate token'); + } + if (btn) { + btn.disabled = false; + btn.innerHTML = originalText; + } + }); +} + +// Export functions for global access (called from template onclick attributes) +window.copyToClipboard = copyToClipboard; +window.regenerateDeviceToken = regenerateDeviceToken; +