From 81fbcfac116d857ff5e0eadff6b158c7aa60f33a Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 13 Feb 2026 12:12:28 -0500 Subject: [PATCH] feat: add RegenerateDeviceToken API endpoint - Add handler to regenerate device auth tokens - Add PUT /api/devices/:id/regenerate-token route - Returns new token and sync URLs for device configuration --- internal/handlers/devices.go | 88 ++++++++++++++++++++++++++++++++ internal/router/device.go | 4 ++ web/src/device-management.ts | 97 ++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 web/src/device-management.ts diff --git a/internal/handlers/devices.go b/internal/handlers/devices.go index cd25172..7349197 100644 --- a/internal/handlers/devices.go +++ b/internal/handlers/devices.go @@ -76,6 +76,7 @@ type DeviceInfo struct { SyncFrequency int32 `json:"sync_frequency_minutes"` CreatedAt time.Time `json:"created_at"` DeviceMetadata json.RawMessage `json:"device_metadata,omitempty"` + AuthToken string `json:"auth_token"` } type DeviceUpdateRequest struct { @@ -263,6 +264,7 @@ func (h *DeviceHandler) ListDevices(c echo.Context) error { SyncFrequency: syncFreq, CreatedAt: device.CreatedAt.Time, DeviceMetadata: device.DeviceMetadata, + AuthToken: device.AuthToken, }) } @@ -306,6 +308,7 @@ func (h *DeviceHandler) GetDevicesData(c echo.Context) ([]DeviceInfo, error) { SyncFrequency: syncFreq, CreatedAt: device.CreatedAt.Time, DeviceMetadata: device.DeviceMetadata, + AuthToken: device.AuthToken, }) } @@ -485,6 +488,91 @@ func (h *DeviceHandler) DeleteDevice(c echo.Context) error { return c.NoContent(http.StatusNoContent) } +func (h *DeviceHandler) RegenerateDeviceToken(c echo.Context) error { + // Verify JWT authentication + userID := c.Get("user_id").(string) + userUUID, err := uuid.Parse(userID) + if err != nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user ID"}) + } + + // Parse device ID from URL parameter + deviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device ID"}) + } + + pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true} + + // Verify device exists and belongs to user + device, err := h.db.GetDevice(c.Request().Context(), pgDeviceID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "device not found"}) + } + + if device.UserID.Bytes != userUUID { + return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"}) + } + + // Generate new auth token + newToken, err := generateDeviceToken() + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate token"}) + } + + // Update device with new token + updatedDevice, err := h.db.UpdateDeviceAuthToken(c.Request().Context(), database.UpdateDeviceAuthTokenParams{ + ID: pgDeviceID, + AuthToken: newToken, + }) + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update token"}) + } + + // Return new token with device info + syncEnabled := updatedDevice.SyncEnabled.Bool && updatedDevice.SyncEnabled.Valid + autoSync := updatedDevice.AutoSync.Bool && updatedDevice.AutoSync.Valid + syncFreq := int32(0) + if updatedDevice.SyncFrequencyMinutes.Valid { + syncFreq = updatedDevice.SyncFrequencyMinutes.Int32 + } + + // Build sync URLs with new token + syncURLs := map[string]string{} + baseURL := h.cfg.BaseURL + + switch updatedDevice.DeviceType { + case "kobo": + syncURLs["sync_url"] = fmt.Sprintf("%s/api/sync/kobo/%s", baseURL, newToken) + syncURLs["markup"] = fmt.Sprintf("%s/api/sync/kobo/%s/markup", baseURL, newToken) + syncURLs["bookmark"] = fmt.Sprintf("%s/api/sync/kobo/%s/bookmark", baseURL, newToken) + syncURLs["init"] = fmt.Sprintf("%s/api/sync/kobo/%s/v1/initialization", baseURL, newToken) + case "koreader": + syncURLs["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", baseURL) + syncURLs["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", baseURL) + syncURLs["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", baseURL) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "message": "Token regenerated successfully", + "auth_token": newToken, + "device": DeviceInfo{ + ID: updatedDevice.ID.Bytes, + DeviceName: updatedDevice.DeviceName, + DeviceType: updatedDevice.DeviceType, + LastSync: (*time.Time)(&updatedDevice.LastSync.Time), + LastSeen: (*time.Time)(&updatedDevice.LastSeen.Time), + SyncEnabled: syncEnabled, + AutoSync: autoSync, + SyncFrequency: syncFreq, + CreatedAt: updatedDevice.CreatedAt.Time, + DeviceMetadata: updatedDevice.DeviceMetadata, + }, + "sync_urls": syncURLs, + }) +} + func (h *DeviceHandler) ApproveDevice(c echo.Context) error { userID := c.Get("user_id").(string) userUUID, err := uuid.Parse(userID) diff --git a/internal/router/device.go b/internal/router/device.go index d1e3d98..e24aa7e 100644 --- a/internal/router/device.go +++ b/internal/router/device.go @@ -19,6 +19,10 @@ func registerDeviceRoutes(cfg *Config) { devices.GET("/:id", cfg.DeviceHandler.GetDevice) devices.PUT("/:id", cfg.DeviceHandler.UpdateDevice) devices.DELETE("/:id", cfg.DeviceHandler.DeleteDevice) + + // Token regeneration endpoint (JWT authentication required) + devices.PUT("/:id/regenerate-token", cfg.DeviceHandler.RegenerateDeviceToken) + devices.GET("/pending", cfg.DeviceHandler.ListPendingRegistrations) devices.GET("/approve/:registration_id", cfg.DeviceHandler.ApproveDevice) devices.POST("/reject/:registration_id", cfg.DeviceHandler.RejectDevice) 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; +