From d70f6c99570bc0b4a62b4186398e33a24bd2cd53 Mon Sep 17 00:00:00 2001 From: kikootwo Date: Fri, 20 Feb 2026 20:44:26 -0500 Subject: [PATCH] Add Deluge integration; revamp admin Jobs & Logs UI Introduce Deluge download client service and tests, remove obsolete rdtclient service, and update qbittorrent integration/tests and download-client interfaces/manager. Large UI refactor for admin pages: Jobs and Logs were redesigned to be responsive (mobile card views + desktop tables), improved headers, dialogs, controls, and better status/detail rendering. Also updated DownloadClient components (card, management, modal), organize-files processor, audible-series integration, and related unit tests to align with integration changes. Minor UX and accessibility tweaks, cron handling/validation adjustments, and a few formatting/cleanup fixes throughout. --- documentation/phase3/qbittorrent.md | 2 +- src/app/admin/jobs/page.tsx | 332 ++++++---- src/app/admin/logs/page.tsx | 384 ++++++----- src/app/admin/users/page.tsx | 611 +++++++++++------- .../download-clients/DownloadClientCard.tsx | 1 + .../DownloadClientManagement.tsx | 12 +- .../download-clients/DownloadClientModal.tsx | 15 +- src/lib/integrations/audible-series.ts | 8 +- src/lib/integrations/deluge.service.ts | 385 +++++++++++ src/lib/integrations/qbittorrent.service.ts | 18 +- src/lib/integrations/rdtclient.service.ts | 105 --- .../interfaces/download-client.interface.ts | 6 +- .../processors/organize-files.processor.ts | 5 +- .../download-client-manager.service.ts | 14 +- tests/api/admin-settings-core.routes.test.ts | 4 +- tests/api/setup-tests.routes.test.ts | 2 +- tests/app/admin-jobs.page.test.tsx | 6 +- tests/app/admin-logs.page.test.tsx | 14 +- tests/app/admin-users.page.test.tsx | 18 +- tests/integrations/deluge.service.test.ts | 440 +++++++++++++ .../integrations/qbittorrent.service.test.ts | 35 +- .../monitor-download.processor.test.ts | 4 +- 22 files changed, 1742 insertions(+), 679 deletions(-) create mode 100644 src/lib/integrations/deluge.service.ts delete mode 100644 src/lib/integrations/rdtclient.service.ts create mode 100644 tests/integrations/deluge.service.test.ts diff --git a/documentation/phase3/qbittorrent.md b/documentation/phase3/qbittorrent.md index 76aa7c7..3c7f648 100644 --- a/documentation/phase3/qbittorrent.md +++ b/documentation/phase3/qbittorrent.md @@ -243,7 +243,7 @@ type TorrentState = - `forcedUP` → `seeding`/`completed` enables monitor to trigger import - `stoppedDL` → `paused` ensures qBittorrent v5.x compatibility -**16. pausedUP/stoppedUP mapped as paused instead of completed** - RDT-Client (and qBittorrent after ratio limits) transitions directly to `pausedUP`/`stoppedUP` without passing through `uploading`/`stalledUP`. The `*UP` suffix means the download phase is complete and the torrent is on the upload side. Both states were incorrectly mapped to `'paused'`, causing the monitor to re-schedule checks indefinitely instead of triggering file organization. Fixed by: +**16. pausedUP/stoppedUP mapped as paused instead of completed** - qBittorrent (after ratio limits) transitions directly to `pausedUP`/`stoppedUP` without passing through `uploading`/`stalledUP`. The `*UP` suffix means the download phase is complete and the torrent is on the upload side. Both states were incorrectly mapped to `'paused'`, causing the monitor to re-schedule checks indefinitely instead of triggering file organization. Fixed by: - `pausedUP` → `seeding` (unified) / `completed` (legacy) — triggers completion in monitor - `stoppedUP` → `seeding` (unified) / `completed` (legacy) — same fix for qBittorrent v5.x - `pausedDL`/`stoppedDL` remain `paused` — download phase genuinely paused diff --git a/src/app/admin/jobs/page.tsx b/src/app/admin/jobs/page.tsx index bfb767a..b812d35 100644 --- a/src/app/admin/jobs/page.tsx +++ b/src/app/admin/jobs/page.tsx @@ -78,13 +78,11 @@ function AdminJobsPageContent() { const showEditDialog = (job: ScheduledJob) => { setEditForm({ schedule: job.schedule, enabled: job.enabled }); - // Check if it's a preset const preset = SCHEDULE_PRESETS.find(p => p.cron === job.schedule); if (preset) { setScheduleMode('preset'); setSelectedPreset(preset.cron); } else { - // Try to parse as custom schedule const parsed = cronToCustomSchedule(job.schedule); if (parsed.type === 'custom') { setScheduleMode('advanced'); @@ -111,7 +109,7 @@ function AdminJobsPageContent() { method: 'POST', }); toast.success(`Job "${jobName}" triggered successfully`); - fetchJobs(); // Refresh list + fetchJobs(); } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Failed to trigger job'; toast.error(errorMsg); @@ -124,7 +122,6 @@ function AdminJobsPageContent() { const saveJobSchedule = async () => { if (!editDialog.job) return; - // Calculate final cron expression based on mode let finalCron: string; if (scheduleMode === 'preset') { finalCron = selectedPreset; @@ -134,7 +131,6 @@ function AdminJobsPageContent() { finalCron = editForm.schedule; } - // Validate cron expression if (!isValidCron(finalCron)) { toast.error('Invalid cron expression. Please check your schedule.'); return; @@ -151,7 +147,7 @@ function AdminJobsPageContent() { }); toast.success(`Job "${editDialog.job.name}" updated successfully`); hideEditDialog(); - fetchJobs(); // Refresh list + fetchJobs(); } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Failed to update job'; toast.error(errorMsg); @@ -173,36 +169,131 @@ function AdminJobsPageContent() { return (
-
- {/* Header */} -
-
-

- Scheduled Jobs -

-

- Manage recurring tasks and automated jobs -

+
+ + {/* Header — stacks on mobile, row on sm+ */} +
+
+
+

+ Scheduled Jobs +

+

+ Manage recurring tasks and automated jobs +

+
+ + + + + Back to Dashboard +
- - - - - Back to Dashboard -
{error && (
-

{error}

+

{error}

)} - {/* Jobs Table */} -
+ {/* Jobs — Card layout on mobile, Table on sm+ */} +
+ {jobs.map((job) => ( +
+ {/* Card header */} +
+
+
+ {job.name} +
+
+ {job.type} +
+
+ + {job.enabled ? 'Enabled' : 'Disabled'} + +
+ + {/* Card body */} +
+
+
+ Schedule +
+
+ {cronToHuman(job.schedule)} +
+
+ {job.schedule} +
+
+
+
+ Last Run +
+
+ {job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'} +
+
+
+ + {/* Card actions */} +
+ + +
+
+ ))} + {jobs.length === 0 && ( +
+

No scheduled jobs found

+
+ )} +
+ + {/* Jobs Table — hidden on mobile, visible on sm+ */} +
@@ -312,31 +403,31 @@ function AdminJobsPageContent() {
  • Library Scan: Automatically scans your media library for new audiobooks
  • Audible Data Refresh: Caches popular and new release audiobooks from Audible
  • -
  • • Trigger jobs manually using the "Trigger Now" button
  • +
  • • Trigger jobs manually using the "Trigger Now" button
  • • Schedule format follows cron syntax (minute hour day month weekday)
{/* Confirmation Dialog */} {confirmDialog.isOpen && ( -
-
-

+
+
+

Confirm Job Trigger

-

+

Are you sure you want to trigger "{confirmDialog.jobName}" now?

-
+
@@ -347,12 +438,27 @@ function AdminJobsPageContent() { {/* Edit Job Dialog */} {editDialog.isOpen && editDialog.job && ( -
-
-

- Edit Job Schedule -

-
+
+
+ {/* Dialog header */} +
+
+

+ Edit Job Schedule +

+ +
+
+ +
{/* Job Name */}
- {/* Schedule Mode Tabs */} + {/* Schedule Mode Tabs — grid on mobile to avoid overflow */}
-
- - - +
+ {(['preset', 'custom', 'advanced'] as const).map((mode) => ( + + ))}
{/* Preset Mode */} @@ -418,16 +507,16 @@ function AdminJobsPageContent() { value={preset.cron} checked={selectedPreset === preset.cron} onChange={(e) => setSelectedPreset(e.target.value)} - className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" + className="mt-1 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0" /> -
+
{preset.label}
-
+
{preset.description}
-
+
{preset.cron}
@@ -445,8 +534,8 @@ function AdminJobsPageContent() {
- {/* Minutes/Hours Interval */} {(customSchedule.type === 'minutes' || customSchedule.type === 'hours') && (
)} - {/* Daily/Weekly/Monthly Time */} {(customSchedule.type === 'daily' || customSchedule.type === 'weekly' || customSchedule.type === 'monthly') && (
)} - {/* Weekly Day Selection */} {customSchedule.type === 'weekly' && (
)} - {/* Preview */}
Preview: {cronToHuman(customScheduleToCron(customSchedule))} @@ -571,30 +655,32 @@ function AdminJobsPageContent() { {/* Advanced Mode */} {scheduleMode === 'advanced' && ( -
- - setEditForm({ ...editForm, schedule: e.target.value })} - placeholder="0 */6 * * *" - className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono" - /> -

- Format: minute hour day month weekday -

-
-
-
• */15 * * * * = Every 15 minutes
-
• 0 */6 * * * = Every 6 hours
-
• 0 0 * * * = Daily at midnight
-
• 0 0 * * 0 = Weekly on Sunday
+
+
+ + setEditForm({ ...editForm, schedule: e.target.value })} + placeholder="0 */6 * * *" + className="w-full px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm" + /> +

+ Format: minute hour day month weekday +

+
+
+
+
*/15 * * * * = Every 15 minutes
+
0 */6 * * * = Every 6 hours
+
0 0 * * * = Daily at midnight
+
0 0 * * 0 = Weekly on Sunday
{editForm.schedule && ( -
+
Preview: {cronToHuman(editForm.schedule)}
@@ -604,34 +690,34 @@ function AdminJobsPageContent() { )}
- {/* Enabled Checkbox */} -
+ {/* Enabled toggle */} +
setEditForm({ ...editForm, enabled: e.target.checked })} - className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600" + className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 flex-shrink-0" /> -
- {/* Actions */} -
+ {/* Dialog footer */} +
diff --git a/src/app/admin/logs/page.tsx b/src/app/admin/logs/page.tsx index a7e6bdc..5ea0b1c 100644 --- a/src/app/admin/logs/page.tsx +++ b/src/app/admin/logs/page.tsx @@ -56,6 +56,119 @@ interface LogsData { }; } +function StatusBadge({ status }: { status: string }) { + const config: Record = { + completed: { dot: 'bg-emerald-500', text: 'text-emerald-700 dark:text-emerald-400', bg: 'bg-emerald-500/10' }, + failed: { dot: 'bg-red-500', text: 'text-red-700 dark:text-red-400', bg: 'bg-red-500/10' }, + active: { dot: 'bg-blue-500', text: 'text-blue-700 dark:text-blue-400', bg: 'bg-blue-500/10' }, + pending: { dot: 'bg-amber-500', text: 'text-amber-700 dark:text-amber-400', bg: 'bg-amber-500/10' }, + delayed: { dot: 'bg-orange-500', text: 'text-orange-700 dark:text-orange-400', bg: 'bg-orange-500/10' }, + stuck: { dot: 'bg-purple-500', text: 'text-purple-700 dark:text-purple-400', bg: 'bg-purple-500/10' }, + }; + const c = config[status] ?? { dot: 'bg-gray-400', text: 'text-gray-600 dark:text-gray-400', bg: 'bg-gray-500/10' }; + + return ( + + + {status.charAt(0).toUpperCase() + status.slice(1)} + + ); +} + +function LogDetails({ log }: { log: Log }) { + return ( +
+ {log.bullJobId && ( +
+ Bull Job ID: + {log.bullJobId} +
+ )} + + {log.events.length > 0 && ( +
+

+ Event Log +

+
+ {log.events.map((event) => { + const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0]; + const levelColor = event.level === 'error' + ? 'text-red-400' + : event.level === 'warn' + ? 'text-amber-400' + : 'text-emerald-400'; + + return ( +
+ [{event.context}] + {' '} + {event.message} + {timestamp} + {event.metadata && Object.keys(event.metadata).length > 0 && ( +
+                      {JSON.stringify(event.metadata, null, 2)}
+                    
+ )} +
+ ); + })} +
+
+ )} + + {log.result && Object.keys(log.result).length > 0 && ( +
+

+ Job Result +

+
+            {JSON.stringify(log.result, null, 2)}
+          
+
+ )} + + {log.errorMessage && ( +
+

+ Error +

+
+ {log.errorMessage} +
+
+ )} +
+ ); +} + +function formatDuration(startedAt: string | null, completedAt: string | null) { + if (!startedAt) return 'N/A'; + if (!completedAt) return 'Running…'; + const durationMs = new Date(completedAt).getTime() - new Date(startedAt).getTime(); + const seconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} + +function formatType(type: string) { + return type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()); +} + +function formatDateShort(dateStr: string) { + const d = new Date(dateStr); + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + if (isToday) { + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + export default function AdminLogsPage() { const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState('all'); @@ -65,9 +178,7 @@ export default function AdminLogsPage() { const { data, error } = useSWR( `/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`, authenticatedFetcher, - { - refreshInterval: 10000, // Refresh every 10 seconds - } + { refreshInterval: 10000 } ); const isLoading = !data && !error; @@ -87,9 +198,7 @@ export default function AdminLogsPage() {
-

- Error Loading Logs -

+

Error Loading Logs

{error?.message || 'Failed to load system logs'}

@@ -101,80 +210,45 @@ export default function AdminLogsPage() { const logs = data?.logs || []; const pagination = data?.pagination; - - const getStatusBadgeColor = (status: string) => { - switch (status) { - case 'completed': - return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'; - case 'failed': - return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'; - case 'active': - return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'; - case 'pending': - return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'; - case 'delayed': - return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400'; - case 'stuck': - return 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'; - default: - return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'; - } - }; - - const formatDuration = (startedAt: string | null, completedAt: string | null) => { - if (!startedAt) return 'N/A'; - if (!completedAt) return 'Running...'; - - const start = new Date(startedAt).getTime(); - const end = new Date(completedAt).getTime(); - const durationMs = end - start; - - const seconds = Math.floor(durationMs / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) return `${hours}h ${minutes % 60}m`; - if (minutes > 0) return `${minutes}m ${seconds % 60}s`; - return `${seconds}s`; - }; + const hasDetails = (log: Log) => log.events.length > 0 || !!log.errorMessage || !!log.bullJobId || (log.result && Object.keys(log.result).length > 0); return (
-
- {/* Header */} -
-
-

- System Logs -

-

- View background jobs and system activity -

+
+ + {/* Header — stacks on mobile, row on sm+ */} +
+
+
+

+ System Logs +

+

+ View background jobs and system activity +

+
+ + + + + Back to Dashboard +
- - - - - Back to Dashboard -
- {/* Filters */} -
+ {/* Filters — full-width stacked on mobile */} +
-
-

@@ -253,13 +393,11 @@ export default function AdminLogsPage() { )} @@ -373,24 +455,31 @@ export default function AdminLogsPage() { {/* Pagination */} {pagination && pagination.totalPages > 1 && ( -
-
- Page {pagination.page} of {pagination.totalPages} ({pagination.total} total logs) +
+
+ Page {pagination.page} of {pagination.totalPages} + ({pagination.total} total logs)
-
+
@@ -403,11 +492,10 @@ export default function AdminLogsPage() {
  • • Logs are automatically refreshed every 10 seconds
  • -
  • • Click "Show Details" to view detailed event logs, job results, and error messages
  • -
  • • Event logs show all internal operations with timestamps (similar to Docker logs)
  • +
  • • Tap "Show Details" to view event logs, job results, and errors
  • +
  • • Event logs show all internal operations with timestamps
  • • Jobs are retried automatically based on their max attempts setting
  • • Use filters to find specific job types or statuses
  • -
  • • All job types are tracked: searches, downloads, file organization, library scans, RSS monitoring, and more
diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 3b3bf73..0d52710 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -41,6 +41,144 @@ interface PendingUser { createdAt: string; } +// Tinted-dot status badge following admin design system +function RoleBadge({ role, isSetupAdmin }: { role: 'user' | 'admin'; isSetupAdmin: boolean }) { + if (isSetupAdmin) { + return ( + + + Setup Admin + + ); + } + if (role === 'admin') { + return ( + + + Admin + + ); + } + return ( + + + User + + ); +} + +function PermissionBadge({ + user, + globalAutoApprove, + onClick, +}: { + user: User; + globalAutoApprove: boolean; + onClick: () => void; +}) { + let badge: React.ReactNode; + if (user.role === 'admin') { + badge = ( + + + + + Full Access + + ); + } else if (globalAutoApprove) { + badge = ( + + Global Default + + ); + } else if (user.autoApproveRequests ?? false) { + badge = ( + + Auto-Approve + + ); + } else { + badge = ( + + Manual + + ); + } + + return ( + + ); +} + +function UserActionsCell({ user, onEdit, onDelete }: { user: User; onEdit: (u: User) => void; onDelete: (u: User) => void }) { + if (user.isSetupAdmin) { + return ( + + + + + Protected + + ); + } + if (user.authProvider === 'oidc') { + return ( + + + + + OIDC Managed + + ); + } + if (user.authProvider === 'local') { + return ( +
+ + +
+ ); + } + // plex or other + return ( + + ); +} + function AdminUsersPageContent() { const { data, error, mutate } = useSWR('/api/admin/users', authenticatedFetcher); const { data: pendingData, error: pendingError, mutate: mutatePending } = useSWR( @@ -86,7 +224,6 @@ function AdminUsersPageContent() { if (globalAutoApproveData?.autoApproveRequests !== undefined) { setGlobalAutoApprove(globalAutoApproveData.autoApproveRequests); } else if (globalAutoApproveData !== undefined && globalAutoApproveData.autoApproveRequests === undefined) { - // API returned but no value - default to true setGlobalAutoApprove(true); } }, [globalAutoApproveData]); @@ -101,9 +238,7 @@ function AdminUsersPageContent() { }, [globalInteractiveSearchData]); const handleGlobalAutoApproveToggle = async (newValue: boolean) => { - // Optimistic update setGlobalAutoApprove(newValue); - try { await fetchJSON('/api/admin/settings/auto-approve', { method: 'PATCH', @@ -111,20 +246,16 @@ function AdminUsersPageContent() { }); toast.success(`Global auto-approve ${newValue ? 'enabled' : 'disabled'}`); mutateGlobalAutoApprove(); - mutate(); // Refresh users list to show updated state + mutate(); } catch (err) { - // Revert on error setGlobalAutoApprove(!newValue); const errorMsg = err instanceof Error ? err.message : 'Failed to update auto-approve setting'; toast.error(errorMsg); - console.error(err); } }; const handleGlobalInteractiveSearchToggle = async (newValue: boolean) => { - // Optimistic update setGlobalInteractiveSearch(newValue); - try { await fetchJSON('/api/admin/settings/interactive-search', { method: 'PATCH', @@ -132,74 +263,51 @@ function AdminUsersPageContent() { }); toast.success(`Global interactive search ${newValue ? 'enabled' : 'disabled'}`); mutateGlobalInteractiveSearch(); - mutate(); // Refresh users list to show updated state + mutate(); } catch (err) { - // Revert on error setGlobalInteractiveSearch(!newValue); const errorMsg = err instanceof Error ? err.message : 'Failed to update interactive search setting'; toast.error(errorMsg); - console.error(err); } }; const handleUserAutoApproveToggle = async (user: User, newValue: boolean) => { - console.log('[AutoApprove] Toggle clicked:', { userId: user.id, username: user.plexUsername, newValue }); - - // Optimistic update const previousUsers = data?.users || []; const optimisticUsers = previousUsers.map((u: User) => u.id === user.id ? { ...u, autoApproveRequests: newValue } : u ); - console.log('[AutoApprove] Applying optimistic update'); mutate({ users: optimisticUsers }, false); - try { - console.log('[AutoApprove] Sending API request...'); - const response = await fetchJSON(`/api/admin/users/${user.id}`, { + await fetchJSON(`/api/admin/users/${user.id}`, { method: 'PUT', - body: JSON.stringify({ - role: user.role, - autoApproveRequests: newValue - }), + body: JSON.stringify({ role: user.role, autoApproveRequests: newValue }), }); - console.log('[AutoApprove] API response received:', response); toast.success(`Auto-approve ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`); - console.log('[AutoApprove] Triggering cache revalidation...'); - mutate(); // Refresh users list + mutate(); } catch (err) { - // Revert on error - console.error('[AutoApprove] Error occurred, reverting:', err); mutate({ users: previousUsers }, false); const errorMsg = err instanceof Error ? err.message : 'Failed to update user auto-approve setting'; toast.error(errorMsg); - console.error(err); } }; const handleUserInteractiveSearchToggle = async (user: User, newValue: boolean) => { - // Optimistic update const previousUsers = data?.users || []; const optimisticUsers = previousUsers.map((u: User) => u.id === user.id ? { ...u, interactiveSearchAccess: newValue } : u ); mutate({ users: optimisticUsers }, false); - try { await fetchJSON(`/api/admin/users/${user.id}`, { method: 'PUT', - body: JSON.stringify({ - role: user.role, - interactiveSearchAccess: newValue - }), + body: JSON.stringify({ role: user.role, interactiveSearchAccess: newValue }), }); toast.success(`Interactive search ${newValue ? 'enabled' : 'disabled'} for ${user.plexUsername}`); - mutate(); // Refresh users list + mutate(); } catch (err) { - // Revert on error mutate({ users: previousUsers }, false); const errorMsg = err instanceof Error ? err.message : 'Failed to update user interactive search setting'; toast.error(errorMsg); - console.error(err); } }; @@ -214,7 +322,6 @@ function AdminUsersPageContent() { const saveUserRole = async () => { if (!editDialog.user) return; - try { setSaving(true); await fetchJSON(`/api/admin/users/${editDialog.user.id}`, { @@ -223,11 +330,10 @@ function AdminUsersPageContent() { }); toast.success(`User "${editDialog.user.plexUsername}" updated successfully`); hideEditDialog(); - mutate(); // Refresh users list + mutate(); } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Failed to update user'; toast.error(errorMsg); - console.error(err); } finally { setSaving(false); } @@ -242,13 +348,12 @@ function AdminUsersPageContent() { }; const closeConfirmDialog = () => { - if (processingUserId) return; // Don't close while processing + if (processingUserId) return; setConfirmDialog({ isOpen: false, type: null, user: null }); }; const handleConfirmAction = async () => { if (!confirmDialog.user) return; - const isApprove = confirmDialog.type === 'approve'; try { setProcessingUserId(confirmDialog.user.id); @@ -261,13 +366,12 @@ function AdminUsersPageContent() { ? `User "${confirmDialog.user.plexUsername}" has been approved` : `User "${confirmDialog.user.plexUsername}" has been rejected` ); - mutatePending(); // Refresh pending users list - if (isApprove) mutate(); // Refresh approved users list + mutatePending(); + if (isApprove) mutate(); closeConfirmDialog(); } catch (err) { const errorMsg = err instanceof Error ? err.message : `Failed to ${isApprove ? 'approve' : 'reject'} user`; toast.error(errorMsg); - console.error(err); } finally { setProcessingUserId(null); } @@ -278,25 +382,23 @@ function AdminUsersPageContent() { }; const closeDeleteDialog = () => { - if (deleting) return; // Don't close while processing + if (deleting) return; setDeleteDialog({ isOpen: false, user: null }); }; const handleDeleteUser = async () => { if (!deleteDialog.user) return; - try { setDeleting(true); const response = await fetchJSON(`/api/admin/users/${deleteDialog.user.id}`, { method: 'DELETE', }); toast.success(response.message || `User "${deleteDialog.user.plexUsername}" has been deleted`); - mutate(); // Refresh users list + mutate(); closeDeleteDialog(); } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Failed to delete user'; toast.error(errorMsg); - console.error(err); } finally { setDeleting(false); } @@ -307,7 +409,6 @@ function AdminUsersPageContent() { await navigator.clipboard.writeText(text); toast.success(`${label} copied to clipboard`); } catch (err) { - console.error('Failed to copy to clipboard:', err); toast.error('Failed to copy to clipboard'); } }; @@ -327,9 +428,7 @@ function AdminUsersPageContent() {
-

- Error Loading Users -

+

Error Loading Users

{error?.message || 'Failed to load users'}

@@ -344,80 +443,81 @@ function AdminUsersPageContent() { return (
-
- {/* Header */} -
-
-

- User Management -

-

- Manage user roles and permissions -

-
-
- - - - - - Back to Dashboard - +
+ + {/* Header — stacks on mobile, row on sm+ */} +
+
+
+

+ User Management +

+

+ Manage user roles and permissions +

+
+
+ + + + + + Back + +
{/* Pending Users Section */} {pendingUsers.length > 0 && ( -
-
-

- +
+
+

+ Pending Registrations ({pendingUsers.length})

-

+

The following users are awaiting approval to access the system.

{pendingUsers.map((user) => (
-
-
-
-
- {user.plexUsername} -
-
- {user.plexEmail || 'No email'} -
-
- Registered: {new Date(user.createdAt).toLocaleString()} • - Provider: {user.authProvider} -
-
+ {/* Pending card — info */} +
+
+ {user.plexUsername} +
+
+ {user.plexEmail || 'No email'} +
+
+ Registered: {new Date(user.createdAt).toLocaleString()} · Provider: {user.authProvider}
-
+ {/* Pending card — actions, full-width on mobile */} +
)} - {/* Users Table */} -
+ {/* Users — Mobile card list (sm:hidden) */} +
+ {users.map((user) => ( +
+ {/* Card header — avatar + name + role badge */} +
+ {user.avatarUrl ? ( + {user.plexUsername} + ) : ( +
+ + + +
+ )} +
+
+
+ {user.plexUsername} +
+ +
+
+ {user.plexEmail || 'No email'} +
+
+
+ + {/* Card body — labeled fields */} +
+
+
+
+ Permissions +
+ setPermissionsUserId(user.id)} + /> +
+
+
+ Requests +
+
+ {user._count.requests} +
+
+
+
+
+ Last Login +
+
+ {user.lastLoginAt + ? new Date(user.lastLoginAt).toLocaleDateString() + : 'Never'} +
+
+
+
+ User ID +
+ +
+
+ + {/* Card actions */} +
+ +
+
+ ))} + {users.length === 0 && ( +
+

No users found

+
+ )} +
+ + {/* Users Table — hidden on mobile, visible on sm+ */} +

- {log.type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())} + {formatType(log.type)}
- - {log.status.toUpperCase()} - + {log.request?.audiobook ? ( @@ -285,7 +423,7 @@ export default function AdminLogsPage() { {log.attempts}/{log.maxAttempts} - {(log.events.length > 0 || log.errorMessage || log.bullJobId || log.result) && ( + {hasDetails(log) && (
-
- {log.bullJobId && ( -
- Bull Job ID: - {log.bullJobId} -
- )} - - {/* Event Logs */} - {log.events.length > 0 && ( -
-

Event Log

-
- {log.events.map((event) => { - const timestamp = new Date(event.createdAt).toISOString().split('T')[1].split('.')[0]; - const levelColor = event.level === 'error' - ? 'text-red-500' - : event.level === 'warn' - ? 'text-yellow-500' - : 'text-green-500'; - - return ( -
- [{event.context}] {event.message} - {timestamp} - {event.metadata && Object.keys(event.metadata).length > 0 && ( -
-                                            {JSON.stringify(event.metadata, null, 2)}
-                                          
- )} -
- ); - })} -
-
- )} - - {/* Result Data */} - {log.result && Object.keys(log.result).length > 0 && ( -
-

Job Result

-
-                                  {JSON.stringify(log.result, null, 2)}
-                                
-
- )} - - {/* Error Message */} - {log.errorMessage && ( -
-

Error

-
- {log.errorMessage} -
-
- )} -
+
@@ -472,15 +668,21 @@ function AdminUsersPageContent() { {users.map((user) => ( - + ))} @@ -643,31 +749,50 @@ function AdminUsersPageContent() {
  • User: Can request audiobooks, view own requests, and search the catalog
  • Admin: Full system access including settings, user management, and all requests
  • -
  • Setup Admin: The initial admin account created during setup - this account is protected and cannot be changed or deleted
  • -
  • Permissions: Click a user's permission badge to manage individual settings (auto-approve, interactive search). Use Global User Permissions to control system-wide defaults. Admins always have full access.
  • -
  • OIDC Users: Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.
  • -
  • Plex Users: Can have their roles changed, but cannot be deleted as access is managed by Plex.
  • -
  • Local Users: Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).
  • +
  • Setup Admin: The initial admin account — protected, cannot be changed or deleted
  • +
  • Permissions: Click a user's permission badge to manage individual settings. Use Global User Permissions for system-wide defaults. Admins always have full access.
  • +
  • OIDC Users: Role management is handled by the identity provider. Cannot be deleted.
  • +
  • Plex Users: Role can be changed, but cannot be deleted (access managed by Plex).
  • +
  • Local Users: Can have roles freely assigned. Can be deleted (requests are preserved).
  • • You cannot change your own role or delete yourself for security reasons
- {/* Edit User Dialog */} + {/* Edit User Dialog — bottom sheet on mobile */} {editDialog.isOpen && editDialog.user && ( -
-
-

- Edit User Role -

-
+
+
+ {/* Dialog header */} +
+

+ Edit User Role +

+ +
+ +
{/* User Info */} -
- {editDialog.user.avatarUrl && ( +
+ {editDialog.user.avatarUrl ? ( {editDialog.user.plexUsername} + ) : ( +
+ + + +
)}
@@ -685,38 +810,34 @@ function AdminUsersPageContent() { Role
-
- {user.avatarUrl && ( + {user.avatarUrl ? ( {user.plexUsername} + ) : ( +
+ + + +
)}
@@ -507,52 +709,14 @@ function AdminUsersPageContent() {
-
- - {user.role.toUpperCase()} - - {user.isSetupAdmin && ( - - SETUP ADMIN - - )} -
+
- + /> {user._count.requests} @@ -563,65 +727,7 @@ function AdminUsersPageContent() { : 'Never'} -
- {user.isSetupAdmin ? ( - - - - - Protected - - ) : user.authProvider === 'oidc' ? ( - - - - - OIDC Managed - - ) : user.authProvider === 'plex' ? ( - - ) : user.authProvider === 'local' ? ( - <> - - - - ) : ( - - )} -
+