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:
@@ -76,6 +76,7 @@ type DeviceInfo struct {
|
|||||||
SyncFrequency int32 `json:"sync_frequency_minutes"`
|
SyncFrequency int32 `json:"sync_frequency_minutes"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
DeviceMetadata json.RawMessage `json:"device_metadata,omitempty"`
|
DeviceMetadata json.RawMessage `json:"device_metadata,omitempty"`
|
||||||
|
AuthToken string `json:"auth_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeviceUpdateRequest struct {
|
type DeviceUpdateRequest struct {
|
||||||
@@ -263,6 +264,7 @@ func (h *DeviceHandler) ListDevices(c echo.Context) error {
|
|||||||
SyncFrequency: syncFreq,
|
SyncFrequency: syncFreq,
|
||||||
CreatedAt: device.CreatedAt.Time,
|
CreatedAt: device.CreatedAt.Time,
|
||||||
DeviceMetadata: device.DeviceMetadata,
|
DeviceMetadata: device.DeviceMetadata,
|
||||||
|
AuthToken: device.AuthToken,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +308,7 @@ func (h *DeviceHandler) GetDevicesData(c echo.Context) ([]DeviceInfo, error) {
|
|||||||
SyncFrequency: syncFreq,
|
SyncFrequency: syncFreq,
|
||||||
CreatedAt: device.CreatedAt.Time,
|
CreatedAt: device.CreatedAt.Time,
|
||||||
DeviceMetadata: device.DeviceMetadata,
|
DeviceMetadata: device.DeviceMetadata,
|
||||||
|
AuthToken: device.AuthToken,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +488,91 @@ func (h *DeviceHandler) DeleteDevice(c echo.Context) error {
|
|||||||
return c.NoContent(http.StatusNoContent)
|
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 {
|
func (h *DeviceHandler) ApproveDevice(c echo.Context) error {
|
||||||
userID := c.Get("user_id").(string)
|
userID := c.Get("user_id").(string)
|
||||||
userUUID, err := uuid.Parse(userID)
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ func registerDeviceRoutes(cfg *Config) {
|
|||||||
devices.GET("/:id", cfg.DeviceHandler.GetDevice)
|
devices.GET("/:id", cfg.DeviceHandler.GetDevice)
|
||||||
devices.PUT("/:id", cfg.DeviceHandler.UpdateDevice)
|
devices.PUT("/:id", cfg.DeviceHandler.UpdateDevice)
|
||||||
devices.DELETE("/:id", cfg.DeviceHandler.DeleteDevice)
|
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("/pending", cfg.DeviceHandler.ListPendingRegistrations)
|
||||||
devices.GET("/approve/:registration_id", cfg.DeviceHandler.ApproveDevice)
|
devices.GET("/approve/:registration_id", cfg.DeviceHandler.ApproveDevice)
|
||||||
devices.POST("/reject/:registration_id", cfg.DeviceHandler.RejectDevice)
|
devices.POST("/reject/:registration_id", cfg.DeviceHandler.RejectDevice)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
Reference in New Issue
Block a user