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
This commit is contained in:
2026-02-13 12:12:28 -05:00
parent 8321149957
commit 81fbcfac11
3 changed files with 189 additions and 0 deletions
+88
View File
@@ -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)
+4
View File
@@ -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)
+97
View File
@@ -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<RegenerateTokenResponse>;
})
.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;