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.
This commit is contained in:
kikootwo
2026-02-20 20:44:26 -05:00
parent 04dbb05a6e
commit d70f6c9957
22 changed files with 1742 additions and 679 deletions
+209 -123
View File
@@ -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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
Scheduled Jobs
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Manage recurring tasks and automated jobs
</p>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
Scheduled Jobs
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage recurring tasks and automated jobs
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-800 dark:text-red-200">{error}</p>
<p className="text-red-800 dark:text-red-200 text-sm">{error}</p>
</div>
)}
{/* Jobs Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{/* Jobs — Card layout on mobile, Table on sm+ */}
<div className="space-y-3 sm:hidden">
{jobs.map((job) => (
<div
key={job.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header */}
<div className="px-4 py-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
{job.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{job.type}
</div>
</div>
<span
className={`flex-shrink-0 mt-0.5 px-2.5 py-0.5 inline-flex text-xs font-medium rounded-full ${
job.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{job.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
{/* Card body */}
<div className="px-4 pb-3 space-y-2 border-t border-gray-100 dark:border-gray-700/60 pt-3">
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Schedule
</div>
<div className="text-sm text-gray-900 dark:text-gray-100">
{cronToHuman(job.schedule)}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
{job.schedule}
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Last Run
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">
{job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'}
</div>
</div>
</div>
{/* Card actions */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60 flex gap-2">
<button
onClick={() => showEditDialog(job)}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg text-sm font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</button>
<button
onClick={() => showConfirmDialog(job.id, job.name)}
disabled={triggering === job.id}
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/40 text-blue-700 dark:text-blue-400 rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{triggering === job.id ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
Running...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Trigger
</>
)}
</button>
</div>
</div>
))}
{jobs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No scheduled jobs found</p>
</div>
)}
</div>
{/* Jobs Table — hidden on mobile, visible on sm+ */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
@@ -312,31 +403,31 @@ function AdminJobsPageContent() {
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> <strong>Library Scan:</strong> Automatically scans your media library for new audiobooks</li>
<li> <strong>Audible Data Refresh:</strong> Caches popular and new release audiobooks from Audible</li>
<li> Trigger jobs manually using the "Trigger Now" button</li>
<li> Trigger jobs manually using the &quot;Trigger Now&quot; button</li>
<li> Schedule format follows cron syntax (minute hour day month weekday)</li>
</ul>
</div>
{/* Confirmation Dialog */}
{confirmDialog.isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl sm:rounded-lg shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
Confirm Job Trigger
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
<p className="text-gray-600 dark:text-gray-400 text-sm mb-6">
Are you sure you want to trigger &quot;{confirmDialog.jobName}&quot; now?
</p>
<div className="flex justify-end gap-3">
<div className="flex gap-3">
<button
onClick={hideConfirmDialog}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium"
>
Cancel
</button>
<button
onClick={triggerJob}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium"
>
Trigger Job
</button>
@@ -347,12 +438,27 @@ function AdminJobsPageContent() {
{/* Edit Job Dialog */}
{editDialog.isOpen && editDialog.job && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Edit Job Schedule
</h3>
<div className="space-y-4 mb-6">
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-2xl max-h-[92vh] sm:max-h-[90vh] overflow-y-auto">
{/* Dialog header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Edit Job Schedule
</h3>
<button
onClick={hideEditDialog}
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close dialog"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="px-5 py-5 space-y-5">
{/* Job Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
@@ -362,46 +468,29 @@ function AdminJobsPageContent() {
type="text"
value={editDialog.job.name}
disabled
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed"
className="w-full px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 border border-gray-300 dark:border-gray-600 rounded-lg cursor-not-allowed text-sm"
/>
</div>
{/* Schedule Mode Tabs */}
{/* Schedule Mode Tabs — grid on mobile to avoid overflow */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Schedule Type
</label>
<div className="flex gap-2 mb-3">
<button
onClick={() => setScheduleMode('preset')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
scheduleMode === 'preset'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Common Schedules
</button>
<button
onClick={() => setScheduleMode('custom')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
scheduleMode === 'custom'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Custom Schedule
</button>
<button
onClick={() => setScheduleMode('advanced')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
scheduleMode === 'advanced'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Advanced (Cron)
</button>
<div className="grid grid-cols-3 gap-1 p-1 bg-gray-100 dark:bg-gray-700/60 rounded-xl mb-4">
{(['preset', 'custom', 'advanced'] as const).map((mode) => (
<button
key={mode}
onClick={() => setScheduleMode(mode)}
className={`px-2 py-2 rounded-lg text-xs font-medium transition-colors ${
scheduleMode === mode
? 'bg-white dark:bg-gray-600 text-gray-900 dark:text-white shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
}`}
>
{mode === 'preset' ? 'Common' : mode === 'custom' ? 'Custom' : 'Advanced'}
</button>
))}
</div>
{/* 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"
/>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{preset.label}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{preset.description}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-1">
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-0.5">
{preset.cron}
</div>
</div>
@@ -445,8 +534,8 @@ function AdminJobsPageContent() {
</label>
<select
value={customSchedule.type}
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as any })}
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"
onChange={(e) => setCustomSchedule({ ...customSchedule, type: e.target.value as CustomSchedule['type'] })}
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 text-sm"
>
<option value="minutes">Every X minutes</option>
<option value="hours">Every X hours</option>
@@ -456,7 +545,6 @@ function AdminJobsPageContent() {
</select>
</div>
{/* Minutes/Hours Interval */}
{(customSchedule.type === 'minutes' || customSchedule.type === 'hours') && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -468,7 +556,7 @@ function AdminJobsPageContent() {
max={customSchedule.type === 'minutes' ? 59 : 23}
value={customSchedule.interval || 1}
onChange={(e) => setCustomSchedule({ ...customSchedule, interval: parseInt(e.target.value, 10) })}
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"
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 text-sm"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Run every {customSchedule.interval || 1} {customSchedule.type}
@@ -476,12 +564,11 @@ function AdminJobsPageContent() {
</div>
)}
{/* Daily/Weekly/Monthly Time */}
{(customSchedule.type === 'daily' || customSchedule.type === 'weekly' || customSchedule.type === 'monthly') && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Hour (0-23)
Hour (023)
</label>
<input
type="number"
@@ -494,12 +581,12 @@ function AdminJobsPageContent() {
time: { hour: parseInt(e.target.value, 10), minute: customSchedule.time?.minute || 0 },
})
}
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"
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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Minute (0-59)
Minute (059)
</label>
<input
type="number"
@@ -512,13 +599,12 @@ function AdminJobsPageContent() {
time: { hour: customSchedule.time?.hour || 0, minute: parseInt(e.target.value, 10) },
})
}
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"
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 text-sm"
/>
</div>
</div>
)}
{/* Weekly Day Selection */}
{customSchedule.type === 'weekly' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -527,7 +613,7 @@ function AdminJobsPageContent() {
<select
value={customSchedule.dayOfWeek || 0}
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfWeek: parseInt(e.target.value, 10) })}
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"
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 text-sm"
>
<option value="0">Sunday</option>
<option value="1">Monday</option>
@@ -540,11 +626,10 @@ function AdminJobsPageContent() {
</div>
)}
{/* Monthly Day Selection */}
{customSchedule.type === 'monthly' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Day of Month (1-31)
Day of Month (131)
</label>
<input
type="number"
@@ -552,12 +637,11 @@ function AdminJobsPageContent() {
max="31"
value={customSchedule.dayOfMonth || 1}
onChange={(e) => setCustomSchedule({ ...customSchedule, dayOfMonth: parseInt(e.target.value, 10) })}
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"
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 text-sm"
/>
</div>
)}
{/* Preview */}
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-sm font-medium text-blue-900 dark:text-blue-200">
Preview: {cronToHuman(customScheduleToCron(customSchedule))}
@@ -571,30 +655,32 @@ function AdminJobsPageContent() {
{/* Advanced Mode */}
{scheduleMode === 'advanced' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Cron Expression
</label>
<input
type="text"
value={editForm.schedule}
onChange={(e) => 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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format: minute hour day month weekday
</p>
<div className="mt-2 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div> */15 * * * * = Every 15 minutes</div>
<div> 0 */6 * * * = Every 6 hours</div>
<div> 0 0 * * * = Daily at midnight</div>
<div> 0 0 * * 0 = Weekly on Sunday</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Cron Expression
</label>
<input
type="text"
value={editForm.schedule}
onChange={(e) => 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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Format: minute hour day month weekday
</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1 font-mono">
<div>*/15 * * * * = Every 15 minutes</div>
<div>0 */6 * * * = Every 6 hours</div>
<div>0 0 * * * = Daily at midnight</div>
<div>0 0 * * 0 = Weekly on Sunday</div>
</div>
</div>
{editForm.schedule && (
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="text-sm font-medium text-blue-900 dark:text-blue-200">
Preview: {cronToHuman(editForm.schedule)}
</div>
@@ -604,34 +690,34 @@ function AdminJobsPageContent() {
)}
</div>
{/* Enabled Checkbox */}
<div className="flex items-center gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
{/* Enabled toggle */}
<div className="flex items-center gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<input
type="checkbox"
id="enabled"
checked={editForm.enabled}
onChange={(e) => 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"
/>
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300">
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer">
Enable this job
</label>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
{/* Dialog footer */}
<div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
<button
onClick={hideEditDialog}
disabled={saving}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 sm:flex-none px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={saveJobSchedule}
disabled={saving}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 sm:flex-none px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
+236 -148
View File
@@ -56,6 +56,119 @@ interface LogsData {
};
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { dot: string; text: string; bg: string }> = {
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 (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${c.dot}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
function LogDetails({ log }: { log: Log }) {
return (
<div className="space-y-4">
{log.bullJobId && (
<div className="flex flex-wrap gap-1.5 items-baseline">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Bull Job ID:</span>
<span className="text-xs text-gray-700 dark:text-gray-300 font-mono break-all">{log.bullJobId}</span>
</div>
)}
{log.events.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Event Log
</h4>
<div className="space-y-px max-h-72 sm:max-h-96 overflow-y-auto bg-gray-950 dark:bg-black/60 rounded-xl p-3 font-mono text-xs">
{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 (
<div key={event.id} className="text-gray-300 leading-relaxed">
<span className={levelColor}>[{event.context}]</span>
{' '}
<span className="break-words">{event.message}</span>
<span className="text-gray-500 ml-2">{timestamp}</span>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<pre className="ml-4 mt-1 text-gray-400 text-xs overflow-x-auto">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
</div>
);
})}
</div>
</div>
)}
{log.result && Object.keys(log.result).length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Job Result
</h4>
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto max-h-48">
{JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{log.errorMessage && (
<div>
<h4 className="text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
Error
</h4>
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl text-xs text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap break-words">
{log.errorMessage}
</div>
</div>
)}
</div>
);
}
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<LogsData>(
`/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() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error Loading Logs
</h3>
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Logs</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load system logs'}
</p>
@@ -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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
System Logs
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
View background jobs and system activity
</p>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
System Logs
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
View background jobs and system activity
</p>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium self-start sm:self-auto flex-shrink-0"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
</div>
{/* Filters */}
<div className="mb-6 flex flex-wrap gap-4">
{/* Filters — full-width stacked on mobile */}
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
Status
</label>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="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"
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="w-full px-3 py-2.5 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 text-sm"
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
@@ -186,16 +260,13 @@ export default function AdminLogsPage() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1.5">
Job Type
</label>
<select
value={typeFilter}
onChange={(e) => {
setTypeFilter(e.target.value);
setPage(1);
}}
className="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"
onChange={(e) => { setTypeFilter(e.target.value); setPage(1); }}
className="w-full px-3 py-2.5 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 text-sm"
>
<option value="all">All Types</option>
<option value="search_indexers">Search Indexers</option>
@@ -215,8 +286,77 @@ export default function AdminLogsPage() {
</div>
</div>
{/* Logs Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{/* Mobile card list — hidden on sm+ */}
<div className="space-y-3 sm:hidden">
{logs.map((log) => (
<div
key={log.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header */}
<div className="px-4 py-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug">
{formatType(log.type)}
</div>
<StatusBadge status={log.status} />
</div>
{/* Related item */}
{log.request?.audiobook ? (
<div className="text-sm mb-2">
<div className="text-gray-700 dark:text-gray-300 font-medium leading-snug">
{log.request.audiobook.title}
</div>
<div className="text-gray-500 dark:text-gray-400 text-xs">
by {log.request.audiobook.author} &middot; {log.request.user.plexUsername}
</div>
</div>
) : (
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">System job</div>
)}
{/* Meta row */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
<span>{formatDateShort(log.createdAt)}</span>
<span>Duration: {formatDuration(log.startedAt, log.completedAt)}</span>
<span>Attempts: {log.attempts}/{log.maxAttempts}</span>
</div>
</div>
{/* Expandable details */}
{hasDetails(log) && (
<>
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="w-full flex items-center justify-between px-4 py-2.5 border-t border-gray-100 dark:border-gray-700/60 text-xs font-medium text-blue-600 dark:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
>
<span>{expandedLog === log.id ? 'Hide Details' : 'Show Details'}</span>
<svg
className={`w-4 h-4 transition-transform duration-200 ${expandedLog === log.id ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedLog === log.id && (
<div className="px-4 pb-4 pt-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700/60">
<LogDetails log={log} />
</div>
)}
</>
)}
</div>
))}
{logs.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No logs found</p>
</div>
)}
</div>
{/* Desktop table — hidden on mobile */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
@@ -253,13 +393,11 @@ export default function AdminLogsPage() {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{log.type.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
{formatType(log.type)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusBadgeColor(log.status)}`}>
{log.status.toUpperCase()}
</span>
<StatusBadge status={log.status} />
</td>
<td className="px-6 py-4">
{log.request?.audiobook ? (
@@ -285,7 +423,7 @@ export default function AdminLogsPage() {
{log.attempts}/{log.maxAttempts}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{(log.events.length > 0 || log.errorMessage || log.bullJobId || log.result) && (
{hasDetails(log) && (
<button
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
@@ -298,63 +436,7 @@ export default function AdminLogsPage() {
{expandedLog === log.id && (
<tr>
<td colSpan={7} className="px-6 py-4 bg-gray-50 dark:bg-gray-900">
<div className="space-y-4">
{log.bullJobId && (
<div>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Bull Job ID: </span>
<span className="text-sm text-gray-600 dark:text-gray-400 font-mono">{log.bullJobId}</span>
</div>
)}
{/* Event Logs */}
{log.events.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Event Log</h4>
<div className="space-y-1 max-h-96 overflow-y-auto bg-black/5 dark:bg-black/30 rounded p-3 font-mono text-xs">
{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 (
<div key={event.id} className="text-gray-800 dark:text-gray-200">
<span className={levelColor}>[{event.context}]</span> {event.message}
<span className="text-gray-500 dark:text-gray-400 ml-2">{timestamp}</span>
{event.metadata && Object.keys(event.metadata).length > 0 && (
<pre className="ml-4 mt-1 text-gray-600 dark:text-gray-400 text-xs">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
</div>
);
})}
</div>
</div>
)}
{/* Result Data */}
{log.result && Object.keys(log.result).length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Job Result</h4>
<pre className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-xs text-blue-900 dark:text-blue-300 font-mono overflow-x-auto">
{JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{/* Error Message */}
{log.errorMessage && (
<div>
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Error</h4>
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-sm text-red-700 dark:text-red-300 font-mono whitespace-pre-wrap">
{log.errorMessage}
</div>
</div>
)}
</div>
<LogDetails log={log} />
</td>
</tr>
)}
@@ -373,24 +455,31 @@ export default function AdminLogsPage() {
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-700 dark:text-gray-300">
Page {pagination.page} of {pagination.totalPages} ({pagination.total} total logs)
<div className="mt-6 flex flex-col sm:flex-row items-center gap-3 sm:justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400 order-2 sm:order-1">
Page {pagination.page} of {pagination.totalPages}
<span className="hidden sm:inline"> ({pagination.total} total logs)</span>
</div>
<div className="flex gap-2">
<div className="flex gap-2 order-1 sm:order-2">
<button
onClick={() => setPage(page - 1)}
disabled={page === 1}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Previous
</button>
<button
onClick={() => setPage(page + 1)}
disabled={page === pagination.totalPages}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
@@ -403,11 +492,10 @@ export default function AdminLogsPage() {
</h3>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> Logs are automatically refreshed every 10 seconds</li>
<li> Click "Show Details" to view detailed event logs, job results, and error messages</li>
<li> Event logs show all internal operations with timestamps (similar to Docker logs)</li>
<li> Tap &quot;Show Details&quot; to view event logs, job results, and errors</li>
<li> Event logs show all internal operations with timestamps</li>
<li> Jobs are retried automatically based on their max attempts setting</li>
<li> Use filters to find specific job types or statuses</li>
<li> All job types are tracked: searches, downloads, file organization, library scans, RSS monitoring, and more</li>
</ul>
</div>
</div>
+366 -245
View File
@@ -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 (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-700 dark:text-blue-400">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-blue-500" />
Setup Admin
</span>
);
}
if (role === 'admin') {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-500/10 text-purple-700 dark:text-purple-400">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-purple-500" />
Admin
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-600 dark:text-gray-400">
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0 bg-gray-400" />
User
</span>
);
}
function PermissionBadge({
user,
globalAutoApprove,
onClick,
}: {
user: User;
globalAutoApprove: boolean;
onClick: () => void;
}) {
let badge: React.ReactNode;
if (user.role === 'admin') {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-500/10 text-purple-700 dark:text-purple-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Full Access
</span>
);
} else if (globalAutoApprove) {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-700 dark:text-blue-400">
Global Default
</span>
);
} else if (user.autoApproveRequests ?? false) {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
Auto-Approve
</span>
);
} else {
badge = (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-500/10 text-gray-600 dark:text-gray-400">
Manual
</span>
);
}
return (
<button
onClick={onClick}
className="inline-flex items-center gap-1.5 text-sm transition-opacity hover:opacity-70"
>
{badge}
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
);
}
function UserActionsCell({ user, onEdit, onDelete }: { user: User; onEdit: (u: User) => void; onDelete: (u: User) => void }) {
if (user.isSetupAdmin) {
return (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Protected</span>
</span>
);
}
if (user.authProvider === 'oidc') {
return (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>OIDC Managed</span>
</span>
);
}
if (user.authProvider === 'local') {
return (
<div className="flex items-center gap-3">
<button
onClick={() => onEdit(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
<button
onClick={() => onDelete(user)}
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 transition-colors"
title="Delete user and all their requests"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Delete</span>
</button>
</div>
);
}
// plex or other
return (
<button
onClick={() => onEdit(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
);
}
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() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error Loading Users
</h3>
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error Loading Users</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load users'}
</p>
@@ -344,80 +443,81 @@ function AdminUsersPageContent() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="sticky top-0 z-10 mb-8 flex items-center justify-between bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
User Management
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Manage user roles and permissions
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setGlobalSettingsOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Global User Permissions</span>
</button>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back to Dashboard</span>
</Link>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{/* Header — stacks on mobile, row on sm+ */}
<div className="sticky top-0 z-10 mb-6 sm:mb-8 bg-gray-50 dark:bg-gray-900 py-4 -mx-4 px-4 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8 border-b border-gray-200 dark:border-gray-800">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
User Management
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Manage user roles and permissions
</p>
</div>
<div className="flex items-center gap-2 self-start sm:self-auto flex-shrink-0">
<button
onClick={() => setGlobalSettingsOpen(true)}
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="hidden sm:inline">Global User Permissions</span>
<span className="sm:hidden">Permissions</span>
</button>
<Link
href="/admin"
className="inline-flex items-center gap-2 px-3 sm:px-4 py-2.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-lg transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span>Back</span>
</Link>
</div>
</div>
</div>
{/* Pending Users Section */}
{pendingUsers.length > 0 && (
<div className="mb-8">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
<h2 className="text-lg font-semibold text-yellow-900 dark:text-yellow-200 mb-4 flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="mb-6 sm:mb-8">
<div className="bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800/60 rounded-xl p-4">
<h2 className="text-base font-semibold text-amber-900 dark:text-amber-200 mb-1 flex items-center gap-2">
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
Pending Registrations ({pendingUsers.length})
</h2>
<p className="text-sm text-yellow-800 dark:text-yellow-300 mb-4">
<p className="text-xs text-amber-700 dark:text-amber-300/80 mb-4">
The following users are awaiting approval to access the system.
</p>
<div className="space-y-3">
{pendingUsers.map((user) => (
<div
key={user.id}
className="bg-white dark:bg-gray-800 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 flex items-center justify-between"
className="bg-white dark:bg-gray-800 border border-amber-200 dark:border-amber-800/40 rounded-xl overflow-hidden"
>
<div className="flex-1">
<div className="flex items-center gap-3">
<div>
<div className="font-medium text-gray-900 dark:text-gray-100">
{user.plexUsername}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{user.plexEmail || 'No email'}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Registered: {new Date(user.createdAt).toLocaleString()}
Provider: {user.authProvider}
</div>
</div>
{/* Pending card — info */}
<div className="px-4 py-3">
<div className="font-medium text-gray-900 dark:text-gray-100 text-sm">
{user.plexUsername}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{user.plexEmail || 'No email'}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Registered: {new Date(user.createdAt).toLocaleString()} &middot; Provider: {user.authProvider}
</div>
</div>
<div className="flex items-center gap-2">
{/* Pending card — actions, full-width on mobile */}
<div className="px-4 py-3 border-t border-amber-100 dark:border-amber-800/30 flex gap-2">
<button
onClick={() => showApproveDialog(user)}
disabled={processingUserId === user.id}
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-emerald-50 dark:bg-emerald-500/10 hover:bg-emerald-100 dark:hover:bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 border border-emerald-200/60 dark:border-emerald-500/20 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
@@ -427,7 +527,7 @@ function AdminUsersPageContent() {
<button
onClick={() => showRejectDialog(user)}
disabled={processingUserId === user.id}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
className="flex-1 inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-red-50 dark:bg-red-500/10 hover:bg-red-100 dark:hover:bg-red-500/20 text-red-700 dark:text-red-400 border border-red-200/60 dark:border-red-500/20 rounded-xl text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -442,8 +542,104 @@ function AdminUsersPageContent() {
</div>
)}
{/* Users Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
{/* Users — Mobile card list (sm:hidden) */}
<div className="space-y-3 sm:hidden">
{users.map((user) => (
<div
key={user.id}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Card header — avatar + name + role badge */}
<div className="px-4 py-3 flex items-start gap-3">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.plexUsername}
className="h-10 w-10 rounded-full flex-shrink-0 mt-0.5"
/>
) : (
<div className="h-10 w-10 rounded-full flex-shrink-0 mt-0.5 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-snug truncate">
{user.plexUsername}
</div>
<RoleBadge role={user.role} isSetupAdmin={user.isSetupAdmin} />
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{user.plexEmail || 'No email'}
</div>
</div>
</div>
{/* Card body — labeled fields */}
<div className="px-4 pb-3 pt-2 space-y-2 border-t border-gray-100 dark:border-gray-700/60">
<div className="grid grid-cols-2 gap-2">
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Permissions
</div>
<PermissionBadge
user={user}
globalAutoApprove={globalAutoApprove}
onClick={() => setPermissionsUserId(user.id)}
/>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Requests
</div>
<div className="text-sm text-gray-900 dark:text-gray-100">
{user._count.requests}
</div>
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
Last Login
</div>
<div className="text-sm text-gray-700 dark:text-gray-300">
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleDateString()
: 'Never'}
</div>
</div>
<div>
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-0.5">
User ID
</div>
<button
onClick={() => copyToClipboard(user.plexId, 'User ID')}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors inline-flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{user.plexId.length > 16 ? `${user.plexId.substring(0, 16)}` : user.plexId}
</button>
</div>
</div>
{/* Card actions */}
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-700/60">
<UserActionsCell user={user} onEdit={showEditDialog} onDelete={showDeleteDialog} />
</div>
</div>
))}
{users.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No users found</p>
</div>
)}
</div>
{/* Users Table — hidden on mobile, visible on sm+ */}
<div className="hidden sm:block bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
@@ -472,15 +668,21 @@ function AdminUsersPageContent() {
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{users.map((user) => (
<tr key={user.id}>
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{user.avatarUrl && (
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.plexUsername}
className="h-10 w-10 rounded-full mr-3"
className="h-10 w-10 rounded-full mr-3 flex-shrink-0"
/>
) : (
<div className="h-10 w-10 rounded-full mr-3 flex-shrink-0 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -507,52 +709,14 @@ function AdminUsersPageContent() {
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.role === 'admin'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{user.role.toUpperCase()}
</span>
{user.isSetupAdmin && (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
SETUP ADMIN
</span>
)}
</div>
<RoleBadge role={user.role} isSetupAdmin={user.isSetupAdmin} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
<PermissionBadge
user={user}
globalAutoApprove={globalAutoApprove}
onClick={() => setPermissionsUserId(user.id)}
className="inline-flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
{user.role === 'admin' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
Full Access
</span>
) : globalAutoApprove ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
Global Default
</span>
) : (user.autoApproveRequests ?? false) ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">
Auto-Approve
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Manual
</span>
)}
<svg className="w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{user._count.requests}
@@ -563,65 +727,7 @@ function AdminUsersPageContent() {
: 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-3">
{user.isSetupAdmin ? (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="Setup admin role cannot be changed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<span>Protected</span>
</span>
) : user.authProvider === 'oidc' ? (
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider (use admin role mapping in settings)">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>OIDC Managed</span>
</span>
) : user.authProvider === 'plex' ? (
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
) : user.authProvider === 'local' ? (
<>
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
<button
onClick={() => showDeleteDialog(user)}
className="inline-flex items-center gap-1 text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="Delete user and all their requests"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span>Delete</span>
</button>
</>
) : (
<button
onClick={() => showEditDialog(user)}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span>Edit Role</span>
</button>
)}
</div>
<UserActionsCell user={user} onEdit={showEditDialog} onDelete={showDeleteDialog} />
</td>
</tr>
))}
@@ -643,31 +749,50 @@ function AdminUsersPageContent() {
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li> <strong>User:</strong> Can request audiobooks, view own requests, and search the catalog</li>
<li> <strong>Admin:</strong> Full system access including settings, user management, and all requests</li>
<li> <strong>Setup Admin:</strong> The initial admin account created during setup - this account is protected and cannot be changed or deleted</li>
<li> <strong>Permissions:</strong> Click a user&apos;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.</li>
<li> <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings. Cannot be deleted as access is managed externally.</li>
<li> <strong>Plex Users:</strong> Can have their roles changed, but cannot be deleted as access is managed by Plex.</li>
<li> <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin). Can be deleted (their requests are preserved for historical records).</li>
<li> <strong>Setup Admin:</strong> The initial admin account protected, cannot be changed or deleted</li>
<li> <strong>Permissions:</strong> Click a user&apos;s permission badge to manage individual settings. Use Global User Permissions for system-wide defaults. Admins always have full access.</li>
<li> <strong>OIDC Users:</strong> Role management is handled by the identity provider. Cannot be deleted.</li>
<li> <strong>Plex Users:</strong> Role can be changed, but cannot be deleted (access managed by Plex).</li>
<li> <strong>Local Users:</strong> Can have roles freely assigned. Can be deleted (requests are preserved).</li>
<li> You cannot change your own role or delete yourself for security reasons</li>
</ul>
</div>
{/* Edit User Dialog */}
{/* Edit User Dialog — bottom sheet on mobile */}
{editDialog.isOpen && editDialog.user && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Edit User Role
</h3>
<div className="space-y-4 mb-6">
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black bg-opacity-50 p-0 sm:p-4">
<div className="bg-white dark:bg-gray-800 rounded-t-2xl sm:rounded-2xl shadow-xl w-full sm:max-w-md">
{/* Dialog header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 px-5 py-4 border-b border-gray-200 dark:border-gray-700 rounded-t-2xl flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Edit User Role
</h3>
<button
onClick={hideEditDialog}
className="p-2 -mr-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
aria-label="Close dialog"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-5 py-5 space-y-4">
{/* User Info */}
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
{editDialog.user.avatarUrl && (
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-xl">
{editDialog.user.avatarUrl ? (
<img
src={editDialog.user.avatarUrl}
alt={editDialog.user.plexUsername}
className="h-12 w-12 rounded-full"
className="h-12 w-12 rounded-full flex-shrink-0"
/>
) : (
<div className="h-12 w-12 rounded-full flex-shrink-0 bg-gray-200 dark:bg-gray-600 flex items-center justify-center">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
)}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -685,38 +810,34 @@ function AdminUsersPageContent() {
Role
</label>
<div className="space-y-2">
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
<input
type="radio"
name="role"
value="user"
checked={editRole === 'user'}
onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')}
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"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
User
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">User</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Can request audiobooks and view own requests
</div>
</div>
</label>
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer">
<label className="flex items-start gap-3 p-3 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors">
<input
type="radio"
name="role"
value="admin"
checked={editRole === 'admin'}
onChange={(e) => setEditRole(e.target.value as 'user' | 'admin')}
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"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
Admin
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">Admin</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Full system access including settings and user management
</div>
</div>
@@ -725,19 +846,19 @@ function AdminUsersPageContent() {
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
{/* Dialog footer */}
<div className="sticky bottom-0 bg-white dark:bg-gray-800 px-5 py-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
<button
onClick={hideEditDialog}
disabled={saving}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 px-4 py-2.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
onClick={saveUserRole}
disabled={saving}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 px-4 py-2.5 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>