Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
@@ -0,0 +1,147 @@
/**
* Component: Admin Active Downloads Table
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { formatDistanceToNow } from 'date-fns';
interface ActiveDownload {
requestId: string;
title: string;
author: string;
progress: number;
speed: number;
eta: number | null;
user: string;
startedAt: Date;
}
interface ActiveDownloadsTableProps {
downloads: ActiveDownload[];
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B/s';
const k = 1024;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function formatETA(seconds: number | null): string {
if (!seconds || seconds <= 0) return 'Unknown';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
export function ActiveDownloadsTable({ downloads }: ActiveDownloadsTableProps) {
if (downloads.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8">
<div className="text-center">
<div className="text-gray-400 dark:text-gray-600 mb-2">
<svg
className="w-12 h-12 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
No Active Downloads
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
All downloads are complete or no requests are currently being processed.
</p>
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Audiobook
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Progress
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Speed
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
ETA
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Started
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{downloads.map((download) => (
<tr
key={download.requestId}
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
>
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{download.title}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{download.author}
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
{download.user}
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 max-w-[100px]">
<div
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${download.progress}%` }}
/>
</div>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 min-w-[3rem] text-right">
{download.progress}%
</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
{formatBytes(download.speed)}
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
{formatETA(download.eta)}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(download.startedAt), { addSuffix: true })}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Component: Admin Dashboard Metric Card
* Documentation: documentation/admin-dashboard.md
*/
'use client';
interface MetricCardProps {
title: string;
value: number | string;
icon: React.ReactNode;
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
subtitle?: string;
}
export function MetricCard({
title,
value,
icon,
variant = 'default',
subtitle,
}: MetricCardProps) {
const variantStyles = {
default: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700',
success: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
warning: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800',
error: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',
};
const iconStyles = {
default: 'text-gray-600 dark:text-gray-400',
success: 'text-green-600 dark:text-green-400',
warning: 'text-yellow-600 dark:text-yellow-400',
error: 'text-red-600 dark:text-red-400',
info: 'text-blue-600 dark:text-blue-400',
};
return (
<div
className={`border rounded-lg p-6 ${variantStyles[variant]} transition-all hover:shadow-md`}
>
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-2">
{value}
</p>
{subtitle && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{subtitle}</p>
)}
</div>
<div className={`p-3 rounded-lg ${iconStyles[variant]}`}>{icon}</div>
</div>
</div>
);
}
@@ -0,0 +1,154 @@
/**
* Component: Admin Recent Requests Table
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { formatDistanceToNow } from 'date-fns';
interface RecentRequest {
requestId: string;
title: string;
author: string;
status: string;
user: string;
createdAt: Date;
completedAt: Date | null;
errorMessage: string | null;
}
interface RecentRequestsTableProps {
requests: RecentRequest[];
}
function getStatusBadge(status: string) {
const styles: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
awaiting_search: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
searching: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
downloading: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
downloaded: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
processing: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
awaiting_import: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
available: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
failed: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
warn: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
cancelled: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
};
const style = styles[status] || 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300';
const labels: Record<string, string> = {
awaiting_search: 'Awaiting Search',
awaiting_import: 'Awaiting Import',
};
const label = labels[status] || status.charAt(0).toUpperCase() + status.slice(1);
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${style}`}
>
{label}
</span>
);
}
export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
if (requests.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8">
<div className="text-center">
<div className="text-gray-400 dark:text-gray-600 mb-2">
<svg
className="w-12 h-12 mx-auto"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fillRule="evenodd"
d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
clipRule="evenodd"
/>
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
No Recent Requests
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
No audiobook requests have been made yet.
</p>
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Audiobook
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Requested
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Completed
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{requests.map((request) => (
<tr
key={request.requestId}
className="hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors"
>
<td className="px-6 py-4">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{request.title}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{request.author}
</div>
{request.errorMessage && (request.status === 'failed' || request.status === 'warn') && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
{request.errorMessage}
</div>
)}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
{request.user}
</td>
<td className="px-6 py-4">{getStatusBadge(request.status)}</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{formatDistanceToNow(new Date(request.createdAt), { addSuffix: true })}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{request.completedAt
? formatDistanceToNow(new Date(request.completedAt), {
addSuffix: true,
})
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+653
View File
@@ -0,0 +1,653 @@
/**
* Component: Admin Jobs Management Page
* Documentation: documentation/backend/services/scheduler.md
*/
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api';
import { ToastProvider, useToast } from '@/components/ui/Toast';
import {
cronToHuman,
SCHEDULE_PRESETS,
customScheduleToCron,
cronToCustomSchedule,
isValidCron,
type CustomSchedule,
} from '@/lib/utils/cron';
interface ScheduledJob {
id: string;
name: string;
type: string;
schedule: string;
enabled: boolean;
lastRun: string | null;
nextRun: string | null;
}
function AdminJobsPageContent() {
const [jobs, setJobs] = useState<ScheduledJob[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [triggering, setTriggering] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
jobId: string;
jobName: string;
}>({ isOpen: false, jobId: '', jobName: '' });
const [editDialog, setEditDialog] = useState<{
isOpen: boolean;
job: ScheduledJob | null;
}>({ isOpen: false, job: null });
const [editForm, setEditForm] = useState({ schedule: '', enabled: true });
const [scheduleMode, setScheduleMode] = useState<'preset' | 'custom' | 'advanced'>('preset');
const [selectedPreset, setSelectedPreset] = useState<string>('');
const [customSchedule, setCustomSchedule] = useState<CustomSchedule>({ type: 'hours', interval: 1 });
const [saving, setSaving] = useState(false);
const toast = useToast();
useEffect(() => {
fetchJobs();
}, []);
const fetchJobs = async () => {
try {
setLoading(true);
const response = await authenticatedFetcher('/api/admin/jobs');
setJobs(response.jobs);
setError(null);
} catch (err) {
setError('Failed to load scheduled jobs');
console.error(err);
} finally {
setLoading(false);
}
};
const showConfirmDialog = (jobId: string, jobName: string) => {
setConfirmDialog({ isOpen: true, jobId, jobName });
};
const hideConfirmDialog = () => {
setConfirmDialog({ isOpen: false, jobId: '', jobName: '' });
};
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');
} else {
setScheduleMode('custom');
setCustomSchedule(parsed);
}
}
setEditDialog({ isOpen: true, job });
};
const hideEditDialog = () => {
setEditDialog({ isOpen: false, job: null });
};
const triggerJob = async () => {
const { jobId, jobName } = confirmDialog;
hideConfirmDialog();
try {
setTriggering(jobId);
await fetchJSON(`/api/admin/jobs/${jobId}/trigger`, {
method: 'POST',
});
toast.success(`Job "${jobName}" triggered successfully`);
fetchJobs(); // Refresh list
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger job';
toast.error(errorMsg);
console.error(err);
} finally {
setTriggering(null);
}
};
const saveJobSchedule = async () => {
if (!editDialog.job) return;
// Calculate final cron expression based on mode
let finalCron: string;
if (scheduleMode === 'preset') {
finalCron = selectedPreset;
} else if (scheduleMode === 'custom') {
finalCron = customScheduleToCron(customSchedule);
} else {
finalCron = editForm.schedule;
}
// Validate cron expression
if (!isValidCron(finalCron)) {
toast.error('Invalid cron expression. Please check your schedule.');
return;
}
try {
setSaving(true);
await fetchJSON(`/api/admin/jobs/${editDialog.job.id}`, {
method: 'PUT',
body: JSON.stringify({
schedule: finalCron,
enabled: editForm.enabled,
}),
});
toast.success(`Job "${editDialog.job.name}" updated successfully`);
hideEditDialog();
fetchJobs(); // Refresh list
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to update job';
toast.error(errorMsg);
console.error(err);
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
);
}
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="mb-8 flex items-center justify-between">
<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>
<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>
</div>
)}
{/* Jobs Table */}
<div className="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>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Schedule
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Last Run
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{jobs.map((job) => (
<tr key={job.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{job.name}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{job.type}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 dark:text-gray-100">
{cronToHuman(job.schedule)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono mt-1">
{job.schedule}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500 dark:text-gray-400">
{job.lastRun ? new Date(job.lastRun).toLocaleString() : 'Never'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
job.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{job.enabled ? 'Enabled' : 'Disabled'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end gap-2">
<button
onClick={() => showEditDialog(job)}
className="inline-flex items-center gap-1 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
title="Edit schedule"
>
<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</span>
</button>
<button
onClick={() => showConfirmDialog(job.id, job.name)}
disabled={triggering === job.id}
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 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>
<span>Running...</span>
</>
) : (
<>
<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>
<span>Trigger Now</span>
</>
)}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{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>
{/* Info Box */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
About Scheduled Jobs
</h3>
<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> 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">
Confirm Job Trigger
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Are you sure you want to trigger &quot;{confirmDialog.jobName}&quot; now?
</p>
<div className="flex justify-end 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"
>
Cancel
</button>
<button
onClick={triggerJob}
className="px-4 py-2 text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
Trigger Job
</button>
</div>
</div>
</div>
)}
{/* 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">
{/* Job Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Job Name
</label>
<input
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"
/>
</div>
{/* Schedule Mode Tabs */}
<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>
{/* Preset Mode */}
{scheduleMode === 'preset' && (
<div className="space-y-2">
{SCHEDULE_PRESETS.map((preset) => (
<label
key={preset.cron}
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"
>
<input
type="radio"
name="preset"
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"
/>
<div className="flex-1">
<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">
{preset.description}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 font-mono mt-1">
{preset.cron}
</div>
</div>
</label>
))}
</div>
)}
{/* Custom Mode */}
{scheduleMode === 'custom' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Frequency
</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"
>
<option value="minutes">Every X minutes</option>
<option value="hours">Every X hours</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</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">
Interval
</label>
<input
type="number"
min="1"
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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Run every {customSchedule.interval || 1} {customSchedule.type}
</p>
</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)
</label>
<input
type="number"
min="0"
max="23"
value={customSchedule.time?.hour || 0}
onChange={(e) =>
setCustomSchedule({
...customSchedule,
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Minute (0-59)
</label>
<input
type="number"
min="0"
max="59"
value={customSchedule.time?.minute || 0}
onChange={(e) =>
setCustomSchedule({
...customSchedule,
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"
/>
</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">
Day of Week
</label>
<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"
>
<option value="0">Sunday</option>
<option value="1">Monday</option>
<option value="2">Tuesday</option>
<option value="3">Wednesday</option>
<option value="4">Thursday</option>
<option value="5">Friday</option>
<option value="6">Saturday</option>
</select>
</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)
</label>
<input
type="number"
min="1"
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"
/>
</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))}
</div>
<div className="text-xs text-blue-700 dark:text-blue-300 font-mono mt-1">
{customScheduleToCron(customSchedule)}
</div>
</div>
</div>
)}
{/* 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>
</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="text-sm font-medium text-blue-900 dark:text-blue-200">
Preview: {cronToHuman(editForm.schedule)}
</div>
</div>
)}
</div>
)}
</div>
{/* Enabled Checkbox */}
<div className="flex items-center gap-2 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"
/>
<label htmlFor="enabled" className="text-sm font-medium text-gray-700 dark:text-gray-300">
Enable this job
</label>
</div>
</div>
{/* Actions */}
<div className="flex justify-end 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"
>
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"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}
export default function AdminJobsPage() {
return (
<ToastProvider>
<AdminJobsPageContent />
</ToastProvider>
);
}
+416
View File
@@ -0,0 +1,416 @@
/**
* Component: Admin System Logs Page
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { useState } from 'react';
import useSWR from 'swr';
import Link from 'next/link';
import { authenticatedFetcher } from '@/lib/utils/api';
interface JobEvent {
id: string;
level: string;
context: string;
message: string;
metadata: any;
createdAt: string;
}
interface Log {
id: string;
bullJobId: string | null;
type: string;
status: string;
priority: number;
attempts: number;
maxAttempts: number;
errorMessage: string | null;
startedAt: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string;
result: any;
events: JobEvent[];
request: {
id: string;
audiobook: {
title: string;
author: string;
} | null;
user: {
plexUsername: string;
};
} | null;
}
interface LogsData {
logs: Log[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
export default function AdminLogsPage() {
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [expandedLog, setExpandedLog] = useState<string | null>(null);
const { data, error } = useSWR<LogsData>(
`/api/admin/logs?page=${page}&limit=50&status=${statusFilter}&type=${typeFilter}`,
authenticatedFetcher,
{
refreshInterval: 10000, // Refresh every 10 seconds
}
);
const isLoading = !data && !error;
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
);
}
if (error) {
return (
<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>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load system logs'}
</p>
</div>
</div>
</div>
);
}
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`;
};
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="mb-8 flex items-center justify-between">
<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>
<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">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
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"
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="delayed">Delayed</option>
<option value="stuck">Stuck</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
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"
>
<option value="all">All Types</option>
<option value="search_indexers">Search Indexers</option>
<option value="download_torrent">Download Torrent</option>
<option value="monitor_download">Monitor Download</option>
<option value="organize_files">Organize Files</option>
<option value="scan_plex">Library Scan</option>
<option value="match_plex">Library Match</option>
<option value="plex_library_scan">Library Scan (Scheduled)</option>
<option value="plex_recently_added_check">Recently Added Check</option>
<option value="audible_refresh">Audible Refresh</option>
<option value="retry_missing_torrents">Retry Missing Torrents</option>
<option value="retry_failed_imports">Retry Failed Imports</option>
<option value="cleanup_seeded_torrents">Cleanup Seeded Torrents</option>
<option value="monitor_rss_feeds">Monitor RSS Feeds</option>
</select>
</div>
</div>
{/* Logs Table */}
<div className="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">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Related Item
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Attempts
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{logs.map((log) => (
<>
<tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{new Date(log.createdAt).toLocaleString()}
</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())}
</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>
</td>
<td className="px-6 py-4">
{log.request?.audiobook ? (
<div className="text-sm">
<div className="font-medium text-gray-900 dark:text-gray-100">
{log.request.audiobook.title}
</div>
<div className="text-gray-500 dark:text-gray-400">
by {log.request.audiobook.author}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500">
User: {log.request.user.plexUsername}
</div>
</div>
) : (
<span className="text-sm text-gray-500 dark:text-gray-400">System job</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{formatDuration(log.startedAt, log.completedAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{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) && (
<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"
>
{expandedLog === log.id ? 'Hide Details' : 'Show Details'}
</button>
)}
</td>
</tr>
{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>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</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>
{/* 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>
<div className="flex gap-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"
>
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"
>
Next
</button>
</div>
</div>
)}
{/* Info Box */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
About System Logs
</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> 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>
</div>
);
}
+300
View File
@@ -0,0 +1,300 @@
/**
* Component: Admin Dashboard Page
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { useEffect } from 'react';
import useSWR from 'swr';
import Link from 'next/link';
import { authenticatedFetcher } from '@/lib/utils/api';
import { MetricCard } from './components/MetricCard';
import { ActiveDownloadsTable } from './components/ActiveDownloadsTable';
import { RecentRequestsTable } from './components/RecentRequestsTable';
export default function AdminDashboard() {
// Fetch data with auto-refresh every 10 seconds
const { data: metrics, error: metricsError } = useSWR(
'/api/admin/metrics',
authenticatedFetcher,
{
refreshInterval: 10000,
}
);
const { data: downloadsData, error: downloadsError } = useSWR(
'/api/admin/downloads/active',
authenticatedFetcher,
{
refreshInterval: 5000, // Refresh downloads more frequently
}
);
const { data: requestsData, error: requestsError } = useSWR(
'/api/admin/requests/recent',
authenticatedFetcher,
{
refreshInterval: 10000,
}
);
const isLoading = !metrics || !downloadsData || !requestsData;
const hasError = metricsError || downloadsError || requestsError;
if (hasError) {
return (
<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 Dashboard
</h3>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{metricsError?.message ||
downloadsError?.message ||
requestsError?.message ||
'Failed to load dashboard data'}
</p>
</div>
</div>
</div>
);
}
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="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
Admin Dashboard
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Monitor system health, active downloads, and recent requests
</p>
</div>
<Link
href="/"
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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span className="hidden sm:inline">Back to Home</span>
<span className="sm:hidden">Home</span>
</Link>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : (
<>
{/* Metrics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<MetricCard
title="Total Requests"
value={metrics.totalRequests}
icon={
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 4.804A7.968 7.968 0 005.5 4c-1.255 0-2.443.29-3.5.804v10A7.969 7.969 0 015.5 14c1.669 0 3.218.51 4.5 1.385A7.962 7.962 0 0114.5 14c1.255 0 2.443.29 3.5.804v-10A7.968 7.968 0 0014.5 4c-1.255 0-2.443.29-3.5.804V12a1 1 0 11-2 0V4.804z" />
</svg>
}
variant="default"
/>
<MetricCard
title="Active Downloads"
value={metrics.activeDownloads}
icon={
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
}
variant={metrics.activeDownloads > 0 ? 'info' : 'default'}
/>
<MetricCard
title="Completed (30d)"
value={metrics.completedLast30Days}
icon={
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
}
variant="success"
/>
<MetricCard
title="Failed (30d)"
value={metrics.failedLast30Days}
icon={
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
}
variant={metrics.failedLast30Days > 0 ? 'error' : 'default'}
/>
<MetricCard
title="Total Users"
value={metrics.totalUsers}
icon={
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
}
variant="default"
/>
<MetricCard
title="System Health"
value={metrics.systemHealth.status.toUpperCase()}
icon={
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
}
variant={
metrics.systemHealth.status === 'healthy'
? 'success'
: metrics.systemHealth.status === 'degraded'
? 'warning'
: 'error'
}
subtitle={
metrics.systemHealth.issues.length > 0
? metrics.systemHealth.issues.join(', ')
: 'All systems operational'
}
/>
</div>
{/* Active Downloads */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Active Downloads
</h2>
<ActiveDownloadsTable downloads={downloadsData.downloads} />
</div>
{/* Recent Requests */}
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Recent Requests
</h2>
<RecentRequestsTable requests={requestsData.requests} />
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Link
href="/admin/settings"
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
>
<div className="flex items-center gap-3">
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z"
clipRule="evenodd"
/>
</svg>
<span className="font-medium text-gray-900 dark:text-gray-100">
Settings
</span>
</div>
</Link>
<Link
href="/admin/users"
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
>
<div className="flex items-center gap-3">
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
<span className="font-medium text-gray-900 dark:text-gray-100">
Users
</span>
</div>
</Link>
<Link
href="/admin/jobs"
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
>
<div className="flex items-center gap-3">
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
clipRule="evenodd"
/>
</svg>
<span className="font-medium text-gray-900 dark:text-gray-100">
Scheduled Jobs
</span>
</div>
</Link>
<Link
href="/admin/logs"
className="block p-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-all"
>
<div className="flex items-center gap-3">
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fillRule="evenodd"
d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
clipRule="evenodd"
/>
</svg>
<span className="font-medium text-gray-900 dark:text-gray-100">
System Logs
</span>
</div>
</Link>
</div>
</>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+493
View File
@@ -0,0 +1,493 @@
/**
* Component: Admin Users Management Page
* Documentation: documentation/admin-dashboard.md
*/
'use client';
import { useState } from 'react';
import useSWR from 'swr';
import Link from 'next/link';
import { authenticatedFetcher, fetchJSON } from '@/lib/utils/api';
import { ToastProvider, useToast } from '@/components/ui/Toast';
import { ConfirmModal } from '@/components/ui/ConfirmModal';
interface User {
id: string;
plexId: string;
plexUsername: string;
plexEmail: string;
role: 'user' | 'admin';
isSetupAdmin: boolean;
avatarUrl: string | null;
createdAt: string;
updatedAt: string;
lastLoginAt: string | null;
_count: {
requests: number;
};
}
interface PendingUser {
id: string;
plexUsername: string;
plexEmail: string | null;
authProvider: string;
createdAt: string;
}
function AdminUsersPageContent() {
const { data, error, mutate } = useSWR('/api/admin/users', authenticatedFetcher);
const { data: pendingData, error: pendingError, mutate: mutatePending } = useSWR(
'/api/admin/users/pending',
authenticatedFetcher
);
const [editDialog, setEditDialog] = useState<{
isOpen: boolean;
user: User | null;
}>({ isOpen: false, user: null });
const [editRole, setEditRole] = useState<'user' | 'admin'>('user');
const [saving, setSaving] = useState(false);
const [processingUserId, setProcessingUserId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
type: 'approve' | 'reject' | null;
user: PendingUser | null;
}>({ isOpen: false, type: null, user: null });
const toast = useToast();
const isLoading = !data && !error;
const pendingUsers: PendingUser[] = pendingData?.users || [];
const showEditDialog = (user: User) => {
setEditRole(user.role);
setEditDialog({ isOpen: true, user });
};
const hideEditDialog = () => {
setEditDialog({ isOpen: false, user: null });
};
const saveUserRole = async () => {
if (!editDialog.user) return;
try {
setSaving(true);
await fetchJSON(`/api/admin/users/${editDialog.user.id}`, {
method: 'PUT',
body: JSON.stringify({ role: editRole }),
});
toast.success(`User "${editDialog.user.plexUsername}" updated successfully`);
hideEditDialog();
mutate(); // Refresh users list
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to update user';
toast.error(errorMsg);
console.error(err);
} finally {
setSaving(false);
}
};
const showApproveDialog = (user: PendingUser) => {
setConfirmDialog({ isOpen: true, type: 'approve', user });
};
const showRejectDialog = (user: PendingUser) => {
setConfirmDialog({ isOpen: true, type: 'reject', user });
};
const closeConfirmDialog = () => {
if (processingUserId) return; // Don't close while processing
setConfirmDialog({ isOpen: false, type: null, user: null });
};
const handleConfirmAction = async () => {
if (!confirmDialog.user) return;
const isApprove = confirmDialog.type === 'approve';
try {
setProcessingUserId(confirmDialog.user.id);
await fetchJSON(`/api/admin/users/${confirmDialog.user.id}/approve`, {
method: 'POST',
body: JSON.stringify({ approve: isApprove }),
});
toast.success(
isApprove
? `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
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);
}
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 p-8">
<div className="max-w-7xl mx-auto">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
</div>
</div>
);
}
if (error) {
return (
<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>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error?.message || 'Failed to load users'}
</p>
</div>
</div>
</div>
);
}
const users: User[] = data?.users || [];
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="mb-8 flex items-center justify-between">
<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>
<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>
{/* 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">
<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">
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"
>
<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>
</div>
</div>
<div className="flex items-center 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"
>
<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" />
</svg>
{processingUserId === user.id ? 'Processing...' : 'Approve'}
</button>
<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"
>
<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" />
</svg>
{processingUserId === user.id ? 'Processing...' : 'Reject'}
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Users Table */}
<div className="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>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Requests
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</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}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{user.avatarUrl && (
<img
src={user.avatarUrl}
alt={user.plexUsername}
className="h-10 w-10 rounded-full mr-3"
/>
)}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{user.plexUsername}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Plex ID: {user.plexId}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900 dark:text-gray-100">
{user.plexEmail || 'N/A'}
</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>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{user._count.requests}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleDateString()
: 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
{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>
) : (
<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>
)}
</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400">No users found</p>
</div>
)}
</div>
{/* Info Box */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
About User Roles
</h3>
<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's role is protected and cannot be changed</li>
<li>• You cannot change your own role for security reasons</li>
</ul>
</div>
{/* Edit User Dialog */}
{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">
{/* User Info */}
<div className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
{editDialog.user.avatarUrl && (
<img
src={editDialog.user.avatarUrl}
alt={editDialog.user.plexUsername}
className="h-12 w-12 rounded-full"
/>
)}
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{editDialog.user.plexUsername}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{editDialog.user.plexEmail || 'No email'}
</div>
</div>
</div>
{/* Role Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
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">
<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"
/>
<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">
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">
<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"
/>
<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">
Full system access including settings and user management
</div>
</div>
</label>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-end 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"
>
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"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
)}
{/* Confirm Approve/Reject Dialog */}
<ConfirmModal
isOpen={confirmDialog.isOpen}
onClose={closeConfirmDialog}
onConfirm={handleConfirmAction}
title={confirmDialog.type === 'approve' ? 'Approve Registration' : 'Reject Registration'}
message={
confirmDialog.type === 'approve'
? `Are you sure you want to approve the registration for "${confirmDialog.user?.plexUsername}"? They will be able to log in immediately.`
: `Are you sure you want to reject and delete the registration for "${confirmDialog.user?.plexUsername}"? This action cannot be undone.`
}
confirmText={confirmDialog.type === 'approve' ? 'Approve' : 'Reject'}
cancelText="Cancel"
isLoading={processingUserId !== null}
variant={confirmDialog.type === 'reject' ? 'danger' : 'primary'}
/>
</div>
</div>
);
}
export default function AdminUsersPage() {
return (
<ToastProvider>
<AdminUsersPageContent />
</ToastProvider>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Backend Mode API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { ConfigurationService } from '@/lib/services/config.service';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const configService = new ConfigurationService();
const backendMode = await configService.getBackendMode();
return NextResponse.json({
backendMode,
isAudiobookshelf: backendMode === 'audiobookshelf'
});
} catch (error) {
console.error('[BackendMode] Failed to get backend mode:', error);
return NextResponse.json(
{ error: 'Failed to get backend mode' },
{ status: 500 }
);
}
});
}
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { mode } = body;
if (!mode || (mode !== 'plex' && mode !== 'audiobookshelf')) {
return NextResponse.json(
{ error: 'Invalid backend mode. Must be "plex" or "audiobookshelf"' },
{ status: 400 }
);
}
const configService = new ConfigurationService();
await configService.setMany([
{ key: 'system.backend_mode', value: mode, category: 'system' }
]);
// Clear library service cache to force re-initialization with new mode
const { clearLibraryServiceCache } = await import('@/lib/services/library');
clearLibraryServiceCache();
console.log(`[BackendMode] Backend mode changed to: ${mode}`);
return NextResponse.json({
success: true,
backendMode: mode,
message: `Backend mode set to ${mode}`
});
} catch (error) {
console.error('[BackendMode] Failed to set backend mode:', error);
return NextResponse.json(
{ error: 'Failed to set backend mode' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,44 @@
/**
* BookDate: Admin Global Toggle
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
async function handler(req: AuthenticatedRequest) {
try {
const body = await req.json();
const { isEnabled } = body;
if (typeof isEnabled !== 'boolean') {
return NextResponse.json(
{ error: 'isEnabled must be a boolean' },
{ status: 400 }
);
}
// Update all BookDate configurations
await prisma.bookDateConfig.updateMany({
data: { isEnabled },
});
return NextResponse.json({
success: true,
isEnabled,
message: `BookDate ${isEnabled ? 'enabled' : 'disabled'} for all users`,
});
} catch (error: any) {
console.error('[BookDate] Admin toggle error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to toggle BookDate' },
{ status: 500 }
);
}
}
export async function PATCH(req: NextRequest) {
return requireAuth(req, (authReq) => requireAdmin(authReq, handler));
}
@@ -0,0 +1,76 @@
/**
* Component: Admin Active Downloads API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Get active downloads with related data
const activeDownloads = await prisma.request.findMany({
where: {
status: 'downloading',
},
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
},
},
user: {
select: {
id: true,
plexUsername: true,
},
},
downloadHistory: {
where: {
downloadStatus: 'downloading',
},
orderBy: {
createdAt: 'desc',
},
take: 1,
select: {
downloadStatus: true,
torrentName: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
take: 20,
});
// Format response
const formatted = activeDownloads.map((download) => ({
requestId: download.id,
title: download.audiobook.title,
author: download.audiobook.author,
status: download.status,
progress: download.progress,
torrentName: download.downloadHistory[0]?.torrentName || null,
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
user: download.user.plexUsername,
startedAt: download.updatedAt,
}));
return NextResponse.json({ downloads: formatted });
} catch (error) {
console.error('[Admin] Failed to fetch active downloads:', error);
return NextResponse.json(
{ error: 'Failed to fetch active downloads' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,70 @@
/**
* Component: Admin Job Execution Status API
* Documentation: documentation/backend/services/jobs.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getJobQueueService } from '@/lib/services/job-queue.service';
/**
* GET /api/admin/job-status/:id
* Get job execution status by database job ID
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
console.log(`[JobStatus] Fetching status for job ID: ${id}`);
const jobQueueService = getJobQueueService();
const job = await jobQueueService.getJob(id);
if (!job) {
console.log(`[JobStatus] Job not found: ${id}`);
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
}
console.log(`[JobStatus] Job ${id} status: ${job.status}, type: ${job.type}`);
return NextResponse.json({
success: true,
job: {
id: job.id,
type: job.type,
status: job.status,
createdAt: job.createdAt,
startedAt: job.startedAt,
completedAt: job.completedAt,
result: job.result,
errorMessage: job.errorMessage,
attempts: job.attempts,
maxAttempts: job.maxAttempts,
},
});
} catch (error) {
console.error('Failed to get job status:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to get job status',
},
{ status: 500 }
);
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Component: Admin Job Update API
* Documentation: documentation/backend/services/scheduler.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getSchedulerService } from '@/lib/services/scheduler.service';
/**
* PUT /api/admin/jobs/:id
* Update a scheduled job
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
const body = await request.json();
const schedulerService = getSchedulerService();
const job = await schedulerService.updateScheduledJob(id, {
name: body.name,
schedule: body.schedule,
enabled: body.enabled,
payload: body.payload,
});
return NextResponse.json({
success: true,
job,
});
} catch (error) {
console.error('Failed to update scheduled job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to update scheduled job',
},
{ status: 500 }
);
}
}
/**
* DELETE /api/admin/jobs/:id
* Delete a scheduled job
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
const schedulerService = getSchedulerService();
await schedulerService.deleteScheduledJob(id);
return NextResponse.json({
success: true,
message: 'Job deleted successfully',
});
} catch (error) {
console.error('Failed to delete scheduled job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to delete scheduled job',
},
{ status: 500 }
);
}
}
@@ -0,0 +1,55 @@
/**
* Component: Admin Job Trigger API
* Documentation: documentation/backend/services/scheduler.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getSchedulerService } from '@/lib/services/scheduler.service';
/**
* POST /api/admin/jobs/:id/trigger
* Manually trigger a scheduled job
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
console.log(`[JobTrigger] Triggering scheduled job: ${id}`);
const schedulerService = getSchedulerService();
const jobId = await schedulerService.triggerJobNow(id);
console.log(`[JobTrigger] Job triggered successfully, database job ID: ${jobId}`);
return NextResponse.json({
success: true,
jobId,
message: 'Job triggered successfully',
});
} catch (error) {
console.error('Failed to trigger job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to trigger job',
},
{ status: 500 }
);
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Component: Admin Jobs Management API
* Documentation: documentation/backend/services/scheduler.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getSchedulerService } from '@/lib/services/scheduler.service';
/**
* GET /api/admin/jobs
* Get all scheduled jobs
*/
export async function GET(request: NextRequest) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
const schedulerService = getSchedulerService();
const jobs = await schedulerService.getScheduledJobs();
return NextResponse.json({
jobs,
});
} catch (error) {
console.error('Failed to get scheduled jobs:', error);
return NextResponse.json(
{
error: 'InternalError',
message: 'Failed to retrieve scheduled jobs',
},
{ status: 500 }
);
}
}
/**
* POST /api/admin/jobs
* Create a new scheduled job
*/
export async function POST(request: NextRequest) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
const body = await request.json();
const schedulerService = getSchedulerService();
const job = await schedulerService.createScheduledJob({
name: body.name,
type: body.type,
schedule: body.schedule,
enabled: body.enabled,
payload: body.payload,
});
return NextResponse.json({
job,
});
} catch (error) {
console.error('Failed to create scheduled job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to create scheduled job',
},
{ status: 500 }
);
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Component: Admin Logs API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '100');
const status = searchParams.get('status') || 'all';
const type = searchParams.get('type') || 'all';
const skip = (page - 1) * limit;
// Build where clause
const where: any = {};
if (status !== 'all') {
where.status = status;
}
if (type !== 'all') {
where.type = type;
}
const [logs, totalCount] = await Promise.all([
prisma.job.findMany({
where,
select: {
id: true,
bullJobId: true,
type: true,
status: true,
priority: true,
attempts: true,
maxAttempts: true,
errorMessage: true,
startedAt: true,
completedAt: true,
createdAt: true,
updatedAt: true,
result: true,
events: {
select: {
id: true,
level: true,
context: true,
message: true,
metadata: true,
createdAt: true,
},
orderBy: {
createdAt: 'asc',
},
},
request: {
select: {
id: true,
audiobook: {
select: {
title: true,
author: true,
},
},
user: {
select: {
plexUsername: true,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
},
skip,
take: limit,
}),
prisma.job.count({ where }),
]);
return NextResponse.json({
logs,
pagination: {
page,
limit,
total: totalCount,
totalPages: Math.ceil(totalCount / limit),
},
});
} catch (error) {
console.error('[Admin] Failed to fetch logs:', error);
return NextResponse.json(
{ error: 'Failed to fetch logs' },
{ status: 500 }
);
}
});
});
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Component: Admin Metrics API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Get system metrics
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [
totalRequests,
activeDownloads,
completedLast30Days,
failedLast30Days,
totalUsers,
] = await Promise.all([
// Total requests (all time)
prisma.request.count(),
// Active downloads (downloading status)
prisma.request.count({
where: {
status: 'downloading',
},
}),
// Completed requests (last 30 days) - 'downloaded' and 'available' statuses
prisma.request.count({
where: {
status: {
in: ['downloaded', 'available'],
},
completedAt: {
gte: thirtyDaysAgo,
},
},
}),
// Failed requests (last 30 days)
prisma.request.count({
where: {
status: 'failed',
updatedAt: {
gte: thirtyDaysAgo,
},
},
}),
// Total users
prisma.user.count(),
]);
// Check system health
const systemHealth = await checkSystemHealth();
return NextResponse.json({
totalRequests,
activeDownloads,
completedLast30Days,
failedLast30Days,
totalUsers,
systemHealth,
});
} catch (error) {
console.error('[Admin] Failed to fetch metrics:', error);
return NextResponse.json(
{ error: 'Failed to fetch metrics' },
{ status: 500 }
);
}
});
});
}
async function checkSystemHealth(): Promise<{
status: 'healthy' | 'degraded' | 'unhealthy';
issues: string[];
}> {
const issues: string[] = [];
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`;
} catch (error) {
issues.push('Database connection failed');
}
// Check for stale downloads (downloading for more than 24 hours)
const oneDayAgo = new Date();
oneDayAgo.setHours(oneDayAgo.getHours() - 24);
const staleDownloads = await prisma.request.count({
where: {
status: 'downloading',
updatedAt: {
lt: oneDayAgo,
},
},
});
if (staleDownloads > 0) {
issues.push(`${staleDownloads} stale downloads (>24h)`);
}
// Determine overall status
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
if (issues.length > 0) {
status = issues.some((i) => i.includes('Database')) ? 'unhealthy' : 'degraded';
}
return { status, issues };
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Component: Admin Plex Library Scan API
* Documentation: documentation/integrations/plex.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin } from '@/lib/middleware/auth';
import { processScanPlex } from '@/lib/processors/scan-plex.processor';
/**
* POST /api/admin/plex/scan
* Trigger a Plex library scan to update availability status for audiobooks
* Admin-only endpoint
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req) => {
return requireAdmin(req, async () => {
try {
// Trigger scan with empty payload (will use configured library ID)
const result = await processScanPlex({
libraryId: undefined,
partial: false,
});
return NextResponse.json({
success: true,
...result,
});
} catch (error) {
console.error('[API] Plex scan failed:', error);
return NextResponse.json(
{
error: 'ScanFailed',
message: error instanceof Error ? error.message : 'Failed to scan Plex library',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,59 @@
/**
* Component: Admin Recent Requests API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Get recent requests
const recentRequests = await prisma.request.findMany({
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
},
},
user: {
select: {
id: true,
plexUsername: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 50,
});
// Format response
const formatted = recentRequests.map((request) => ({
requestId: request.id,
title: request.audiobook.title,
author: request.audiobook.author,
status: request.status,
user: request.user.plexUsername,
createdAt: request.createdAt,
completedAt: request.completedAt,
errorMessage: request.errorMessage,
}));
return NextResponse.json({ requests: formatted });
} catch (error) {
console.error('[Admin] Failed to fetch recent requests:', error);
return NextResponse.json(
{ error: 'Failed to fetch recent requests' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,66 @@
/**
* Audiobookshelf Libraries API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
export async function GET(request: NextRequest) {
console.log('[ABS Libraries] GET request received');
return requireAuth(request, async (req: AuthenticatedRequest) => {
console.log('[ABS Libraries] Auth passed, user:', req.user);
return requireAdmin(req, async () => {
console.log('[ABS Libraries] Admin check passed');
try {
// Use getConfigService like Plex endpoint does
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const serverUrl = await configService.get('audiobookshelf.server_url');
const apiToken = await configService.get('audiobookshelf.api_token');
console.log('[ABS Libraries] Config loaded:', { hasServerUrl: !!serverUrl, hasApiToken: !!apiToken });
if (!serverUrl || !apiToken) {
return NextResponse.json(
{ error: 'Audiobookshelf not configured' },
{ status: 400 }
);
}
// Fetch libraries from Audiobookshelf
const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/libraries`, {
headers: {
'Authorization': `Bearer ${apiToken}`,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: 'Failed to fetch libraries from Audiobookshelf' },
{ status: response.status }
);
}
const data = await response.json();
// Filter to only audiobook libraries and map to expected format
const libraries = (data.libraries || [])
.filter((lib: any) => lib.mediaType === 'book')
.map((lib: any) => ({
id: lib.id,
name: lib.name,
type: lib.mediaType,
itemCount: lib.stats?.totalItems || 0,
}));
return NextResponse.json({ libraries });
} catch (error) {
console.error('[Admin] Failed to fetch ABS libraries:', error);
return NextResponse.json(
{ error: 'Failed to fetch libraries' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,51 @@
/**
* Audiobookshelf Settings API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { ConfigUpdate } from '@/lib/services/config.service';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { serverUrl, apiToken, libraryId } = body;
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
// Build updates array, skipping masked values
const updates: ConfigUpdate[] = [
{ key: 'audiobookshelf.server_url', value: serverUrl || '' },
{ key: 'audiobookshelf.library_id', value: libraryId || '' },
];
// Only update API token if it's not the masked placeholder
if (apiToken && !apiToken.startsWith('••••')) {
updates.push({
key: 'audiobookshelf.api_token',
value: apiToken,
encrypted: true,
});
}
// Update configuration
await configService.setMany(updates);
return NextResponse.json({
success: true,
message: 'Audiobookshelf settings saved successfully'
});
} catch (error) {
console.error('[Admin] Failed to save Audiobookshelf settings:', error);
return NextResponse.json(
{ error: 'Failed to save settings' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,135 @@
/**
* Component: Local Admin Password Change API
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireLocalAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import bcrypt from 'bcrypt';
/**
* POST /api/admin/settings/change-password
* Change password for local admin user
*
* Security:
* - Only available to local admin (isSetupAdmin=true AND plexId starts with 'local-')
* - Requires current password verification
* - New password must be at least 8 characters
* - New password must be different from current password
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireLocalAdmin(req, async (authenticatedReq: AuthenticatedRequest) => {
try {
const { currentPassword, newPassword, confirmPassword } = await request.json();
// Validate input
if (!currentPassword || !newPassword || !confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'All fields are required',
},
{ status: 400 }
);
}
// Validate new password length
if (newPassword.length < 8) {
return NextResponse.json(
{
success: false,
error: 'New password must be at least 8 characters',
},
{ status: 400 }
);
}
// Validate passwords match
if (newPassword !== confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'New passwords do not match',
},
{ status: 400 }
);
}
// Validate new password is different from current
if (currentPassword === newPassword) {
return NextResponse.json(
{
success: false,
error: 'New password must be different from current password',
},
{ status: 400 }
);
}
// Get user from database
const user = await prisma.user.findUnique({
where: { id: authenticatedReq.user!.id },
select: {
id: true,
authToken: true,
plexId: true,
isSetupAdmin: true,
},
});
if (!user || !user.authToken) {
return NextResponse.json(
{
success: false,
error: 'User not found or invalid account type',
},
{ status: 404 }
);
}
// Verify current password
const currentPasswordValid = await bcrypt.compare(currentPassword, user.authToken);
if (!currentPasswordValid) {
return NextResponse.json(
{
success: false,
error: 'Current password is incorrect',
},
{ status: 400 }
);
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password in database
await prisma.user.update({
where: { id: user.id },
data: {
authToken: hashedPassword,
updatedAt: new Date(),
},
});
console.log(`[Auth] Local admin password changed successfully for user ${user.id}`);
return NextResponse.json({
success: true,
message: 'Password changed successfully',
});
} catch (error) {
console.error('[Auth] Failed to change password:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to change password',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,77 @@
/**
* Component: Admin Download Client Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { type, url, username, password } = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
{ error: 'Type, URL, username, and password are required' },
{ status: 400 }
);
}
// Validate type
if (type !== 'qbittorrent' && type !== 'transmission') {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or transmission' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'download_client_type' },
update: { value: type },
create: { key: 'download_client_type', value: type },
});
await prisma.configuration.upsert({
where: { key: 'download_client_url' },
update: { value: url },
create: { key: 'download_client_url', value: url },
});
await prisma.configuration.upsert({
where: { key: 'download_client_username' },
update: { value: username },
create: { key: 'download_client_username', value: username },
});
// Only update password if it's not the masked value
if (!password.startsWith('••••')) {
await prisma.configuration.upsert({
where: { key: 'download_client_password' },
update: { value: password },
create: { key: 'download_client_password', value: password },
});
}
console.log('[Admin] Download client settings updated');
return NextResponse.json({
success: true,
message: 'Download client settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update download client settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
+51
View File
@@ -0,0 +1,51 @@
/**
* OIDC Settings API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { enabled, providerName, issuerUrl, clientId, clientSecret } = body;
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
// Build config updates
const updates: Array<{key: string; value: string; encrypted?: boolean}> = [
{ key: 'oidc.enabled', value: enabled ? 'true' : 'false' },
{ key: 'oidc.provider_name', value: providerName || '' },
{ key: 'oidc.issuer_url', value: issuerUrl || '' },
{ key: 'oidc.client_id', value: clientId || '' },
];
// Only update client secret if provided (not masked)
if (clientSecret && !clientSecret.includes('••')) {
updates.push({
key: 'oidc.client_secret',
value: clientSecret,
encrypted: true
});
}
await configService.setMany(updates);
return NextResponse.json({
success: true,
message: 'OIDC settings saved successfully'
});
} catch (error) {
console.error('[Admin] Failed to save OIDC settings:', error);
return NextResponse.json(
{ error: 'Failed to save settings' },
{ status: 500 }
);
}
});
});
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Component: Admin Paths Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, metadataTaggingEnabled } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
{ error: 'Download directory and media directory are required' },
{ status: 400 }
);
}
// Validate paths are not the same
if (downloadDir === mediaDir) {
return NextResponse.json(
{ error: 'Download and media directories must be different' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'download_dir' },
update: { value: downloadDir },
create: { key: 'download_dir', value: downloadDir },
});
await prisma.configuration.upsert({
where: { key: 'media_dir' },
update: { value: mediaDir },
create: { key: 'media_dir', value: mediaDir },
});
// Update metadata tagging setting
await prisma.configuration.upsert({
where: { key: 'metadata_tagging_enabled' },
update: { value: String(metadataTaggingEnabled ?? true) },
create: {
key: 'metadata_tagging_enabled',
value: String(metadataTaggingEnabled ?? true),
category: 'automation',
description: 'Automatically tag audio files with correct metadata during file organization',
},
});
console.log('[Admin] Paths settings updated');
return NextResponse.json({
success: true,
message: 'Paths settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update paths settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,66 @@
/**
* Component: Plex Libraries API Route
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getPlexService } from '@/lib/integrations/plex.service';
/**
* GET /api/admin/settings/plex/libraries
* Fetch available Plex libraries
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const plexService = await getPlexService();
// Get Plex configuration
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const plexUrl = await configService.get('plex_url');
const plexToken = await configService.get('plex_token');
if (!plexUrl || !plexToken) {
return NextResponse.json(
{
success: false,
error: 'Plex not configured',
message: 'Please configure Plex URL and token first',
},
{ status: 400 }
);
}
// Fetch all libraries from Plex
const libraries = await plexService.getLibraries(plexUrl, plexToken);
// Filter for audiobook/music libraries (type 8 or 15)
const audioLibraries = libraries.filter((lib: any) =>
lib.type === 'artist' || lib.type === 'music' || lib.title.toLowerCase().includes('audio')
);
return NextResponse.json({
success: true,
libraries: audioLibraries.map((lib: any) => ({
id: lib.key,
title: lib.title,
type: lib.type,
})),
});
} catch (error) {
console.error('[Plex] Failed to fetch libraries:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch Plex libraries',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
});
});
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Component: Admin Plex Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getPlexService } from '@/lib/integrations/plex.service';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, token, libraryId } = await request.json();
if (!url || !token || !libraryId) {
return NextResponse.json(
{ error: 'URL, token, and library ID are required' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'plex_url' },
update: { value: url },
create: { key: 'plex_url', value: url },
});
// Only update token if it's not the masked value
if (!token.startsWith('••••')) {
await prisma.configuration.upsert({
where: { key: 'plex_token' },
update: { value: token },
create: { key: 'plex_token', value: token },
});
}
await prisma.configuration.upsert({
where: { key: 'plex_audiobook_library_id' },
update: { value: libraryId },
create: { key: 'plex_audiobook_library_id', value: libraryId },
});
// Fetch and save machine identifier (for server-specific access tokens)
// This is needed for BookDate per-user rating functionality
try {
const plexService = getPlexService();
const actualToken = token.startsWith('••••') ? null : token;
// Get token from DB if it was masked
const tokenToUse = actualToken || (await prisma.configuration.findUnique({
where: { key: 'plex_token' },
}))?.value;
if (tokenToUse) {
const serverInfo = await plexService.testConnection(url, tokenToUse);
if (serverInfo.success && serverInfo.info?.machineIdentifier) {
await prisma.configuration.upsert({
where: { key: 'plex_machine_identifier' },
update: { value: serverInfo.info.machineIdentifier },
create: { key: 'plex_machine_identifier', value: serverInfo.info.machineIdentifier },
});
console.log('[Admin] machineIdentifier updated:', serverInfo.info.machineIdentifier);
} else {
console.warn('[Admin] Could not fetch machineIdentifier');
}
}
} catch (error) {
console.error('[Admin] Error fetching machineIdentifier:', error);
// Don't fail the request if machineIdentifier fetch fails
}
console.log('[Admin] Plex settings updated');
return NextResponse.json({
success: true,
message: 'Plex settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update Plex settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,126 @@
/**
* Component: Prowlarr Indexers API Route
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { getConfigService } from '@/lib/services/config.service';
interface SavedIndexerConfig {
id: number;
name: string;
priority: number;
seedingTimeMinutes: number;
rssEnabled?: boolean;
}
/**
* GET /api/admin/settings/prowlarr/indexers
* Fetch available Prowlarr indexers with configuration
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const prowlarrService = await getProwlarrService();
const configService = getConfigService();
// Fetch indexers from Prowlarr
const indexers = await prowlarrService.getIndexers();
// Get saved indexer configuration (matches wizard format)
const savedConfigStr = await configService.get('prowlarr_indexers');
const savedIndexers: SavedIndexerConfig[] = savedConfigStr ? JSON.parse(savedConfigStr) : [];
// Merge with defaults (wizard format: array of {id, name, priority, seedingTimeMinutes})
const savedIndexersMap = new Map<number, SavedIndexerConfig>(
savedIndexers.map((idx) => [idx.id, idx])
);
const indexersWithConfig = indexers.map((indexer: any) => {
const saved = savedIndexersMap.get(indexer.id);
return {
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
privacy: indexer.privacy,
enabled: !!saved, // Enabled if in saved list
priority: saved?.priority || 10,
seedingTimeMinutes: saved?.seedingTimeMinutes ?? 0,
rssEnabled: saved?.rssEnabled ?? false,
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
};
});
return NextResponse.json({
success: true,
indexers: indexersWithConfig,
});
} catch (error) {
console.error('[Prowlarr] Failed to fetch indexers:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch Prowlarr indexers',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
});
});
}
/**
* PUT /api/admin/settings/prowlarr/indexers
* Save indexer configuration
*/
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { indexers } = await req.json();
// Filter to only enabled indexers and convert to wizard format
const enabledIndexers = indexers
.filter((indexer: any) => indexer.enabled)
.map((indexer: any) => ({
id: indexer.id,
name: indexer.name,
priority: indexer.priority,
seedingTimeMinutes: indexer.seedingTimeMinutes,
rssEnabled: indexer.rssEnabled || false,
}));
// Save to configuration (matches wizard format)
const configService = getConfigService();
await configService.setMany([
{
key: 'prowlarr_indexers',
value: JSON.stringify(enabledIndexers),
category: 'indexer',
description: 'Prowlarr indexer settings (enabled, priority, seeding time)',
},
]);
return NextResponse.json({
success: true,
message: 'Indexer configuration saved',
});
} catch (error) {
console.error('[Prowlarr] Failed to save indexer config:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to save indexer configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,57 @@
/**
* Component: Admin Prowlarr Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, apiKey } = await request.json();
if (!url || !apiKey) {
return NextResponse.json(
{ error: 'URL and API key are required' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'prowlarr_url' },
update: { value: url },
create: { key: 'prowlarr_url', value: url },
});
// Only update API key if it's not the masked value
if (!apiKey.startsWith('••••')) {
await prisma.configuration.upsert({
where: { key: 'prowlarr_api_key' },
update: { value: apiKey },
create: { key: 'prowlarr_api_key', value: apiKey },
});
}
console.log('[Admin] Prowlarr settings updated');
return NextResponse.json({
success: true,
message: 'Prowlarr settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update Prowlarr settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,37 @@
/**
* Registration Settings API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { enabled, requireAdminApproval } = body;
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
await configService.setMany([
{ key: 'auth.registration_enabled', value: enabled ? 'true' : 'false' },
{ key: 'auth.require_admin_approval', value: requireAdminApproval ? 'true' : 'false' },
]);
return NextResponse.json({
success: true,
message: 'Registration settings saved successfully'
});
} catch (error) {
console.error('[Admin] Failed to save registration settings:', error);
return NextResponse.json(
{ error: 'Failed to save settings' },
{ status: 500 }
);
}
});
});
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Component: Admin Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Fetch all configuration
const configs = await prisma.configuration.findMany();
const configMap = new Map(configs.map((c) => [c.key, c.value]));
// Mask sensitive values
const maskValue = (key: string, value: string | null | undefined) => {
const sensitiveKeys = ['token', 'api_key', 'password'];
if (value && sensitiveKeys.some((k) => key.includes(k))) {
return '••••••••••••';
}
return value || '';
};
// Build response object
const settings = {
backendMode: configMap.get('system.backend_mode') || 'plex',
plex: {
url: configMap.get('plex_url') || '',
token: maskValue('token', configMap.get('plex_token')),
libraryId: configMap.get('plex_audiobook_library_id') || '',
},
audiobookshelf: {
serverUrl: configMap.get('audiobookshelf.server_url') || '',
apiToken: maskValue('api_token', configMap.get('audiobookshelf.api_token')),
libraryId: configMap.get('audiobookshelf.library_id') || '',
},
oidc: {
enabled: configMap.get('oidc.enabled') === 'true',
providerName: configMap.get('oidc.provider_name') || '',
issuerUrl: configMap.get('oidc.issuer_url') || '',
clientId: configMap.get('oidc.client_id') || '',
clientSecret: maskValue('client_secret', configMap.get('oidc.client_secret')),
},
registration: {
enabled: configMap.get('auth.registration_enabled') === 'true',
requireAdminApproval: configMap.get('auth.require_admin_approval') === 'true',
},
prowlarr: {
url: configMap.get('prowlarr_url') || '',
apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')),
},
downloadClient: {
type: configMap.get('download_client_type') || 'qbittorrent',
url: configMap.get('download_client_url') || '',
username: configMap.get('download_client_username') || '',
password: maskValue('password', configMap.get('download_client_password')),
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
},
paths: {
downloadDir: configMap.get('download_dir') || '/downloads',
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
},
general: {
appName: configMap.get('app_name') || 'ReadMeABook',
allowRegistrations: configMap.get('allow_registrations') === 'true',
maxConcurrentDownloads: parseInt(
configMap.get('max_concurrent_downloads') || '3'
),
autoApproveRequests: configMap.get('auto_approve_requests') === 'true',
},
};
return NextResponse.json(settings);
} catch (error) {
console.error('[Admin] Failed to fetch settings:', error);
return NextResponse.json(
{ error: 'Failed to fetch settings' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,71 @@
/**
* Component: Admin Settings Test Download Client API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { type, url, username, password } = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
{ success: false, error: 'All fields are required' },
{ status: 400 }
);
}
if (type !== 'qbittorrent') {
return NextResponse.json(
{ success: false, error: 'Only qBittorrent is currently supported' },
{ status: 400 }
);
}
// If password is masked, fetch the actual value from database
let actualPassword = password;
if (password.startsWith('••••')) {
const storedPassword = await prisma.configuration.findUnique({
where: { key: 'download_client_password' },
});
if (!storedPassword?.value) {
return NextResponse.json(
{ success: false, error: 'No stored password found. Please re-enter your download client password.' },
{ status: 400 }
);
}
actualPassword = storedPassword.value;
}
// Test connection with credentials
const version = await QBittorrentService.testConnectionWithCredentials(
url,
username,
actualPassword
);
return NextResponse.json({
success: true,
version,
});
} catch (error) {
console.error('[Admin Settings] Download client test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to download client',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,84 @@
/**
* Component: Admin Settings Test Plex API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getPlexService } from '@/lib/integrations/plex.service';
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, token } = await request.json();
if (!url || !token) {
return NextResponse.json(
{ success: false, error: 'URL and token are required' },
{ status: 400 }
);
}
// If token is masked, fetch the actual value from database
let actualToken = token;
if (token.startsWith('••••')) {
const storedToken = await prisma.configuration.findUnique({
where: { key: 'plex_token' },
});
if (!storedToken?.value) {
return NextResponse.json(
{ success: false, error: 'No stored token found. Please re-enter your Plex token.' },
{ status: 400 }
);
}
actualToken = storedToken.value;
}
const plexService = getPlexService();
// Test connection and get server info
const connectionResult = await plexService.testConnection(url, actualToken);
if (!connectionResult.success || !connectionResult.info) {
return NextResponse.json(
{ success: false, error: connectionResult.message },
{ status: 400 }
);
}
// Get libraries
const libraries = await plexService.getLibraries(url, actualToken);
// Format server name safely
const serverName = connectionResult.info
? `${connectionResult.info.platform || 'Plex Server'} v${connectionResult.info.version || 'Unknown'}`
: 'Plex Server';
return NextResponse.json({
success: true,
serverName,
version: connectionResult.info?.version || 'Unknown',
machineIdentifier: connectionResult.info?.machineIdentifier || 'unknown',
libraries: libraries.map((lib) => ({
id: lib.id,
title: lib.title,
type: lib.type,
})),
});
} catch (error) {
console.error('[Admin Settings] Plex test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Plex',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,73 @@
/**
* Component: Admin Settings Test Prowlarr API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, apiKey } = await request.json();
if (!url || !apiKey) {
return NextResponse.json(
{ success: false, error: 'URL and API key are required' },
{ status: 400 }
);
}
// If API key is masked, fetch the actual value from database
let actualApiKey = apiKey;
if (apiKey.startsWith('••••')) {
const storedApiKey = await prisma.configuration.findUnique({
where: { key: 'prowlarr_api_key' },
});
if (!storedApiKey?.value) {
return NextResponse.json(
{ success: false, error: 'No stored API key found. Please re-enter your Prowlarr API key.' },
{ status: 400 }
);
}
actualApiKey = storedApiKey.value;
}
// Create a new ProwlarrService instance with test credentials
const prowlarrService = new ProwlarrService(url, actualApiKey);
// Test connection and get indexers
const indexers = await prowlarrService.getIndexers();
// Only return enabled indexers
const enabledIndexers = indexers.filter((indexer) => indexer.enable);
return NextResponse.json({
success: true,
indexerCount: enabledIndexers.length,
totalIndexers: indexers.length,
indexers: enabledIndexers.map((indexer) => ({
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
supportsRss: indexer.capabilities?.supportsRss !== false,
})),
});
} catch (error) {
console.error('[Admin Settings] Prowlarr test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Prowlarr',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,75 @@
/**
* User Approval API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const body = await request.json();
const { approve } = body; // true = approve, false = reject
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
plexUsername: true,
registrationStatus: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
if (user.registrationStatus !== 'pending_approval') {
return NextResponse.json(
{ error: 'User is not pending approval' },
{ status: 400 }
);
}
if (approve) {
// Approve user
await prisma.user.update({
where: { id },
data: { registrationStatus: 'approved' },
});
return NextResponse.json({
success: true,
message: `User ${user.plexUsername} has been approved`
});
} else {
// Reject user (delete the account)
await prisma.user.delete({
where: { id },
});
return NextResponse.json({
success: true,
message: `User ${user.plexUsername} has been rejected and removed`
});
}
} catch (error) {
console.error('[Admin] Failed to approve/reject user:', error);
return NextResponse.json(
{ error: 'Failed to process user approval' },
{ status: 500 }
);
}
});
});
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Component: Admin User Update API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const body = await request.json();
const { role } = body;
// Validate role
if (!role || (role !== 'user' && role !== 'admin')) {
return NextResponse.json(
{ error: 'Invalid role. Must be "user" or "admin"' },
{ status: 400 }
);
}
// Prevent user from demoting themselves
if (req.user && id === req.user.sub) {
return NextResponse.json(
{ error: 'You cannot change your own role' },
{ status: 403 }
);
}
// Check if user is the setup admin
const targetUser = await prisma.user.findUnique({
where: { id },
select: {
isSetupAdmin: true,
plexUsername: true,
},
});
if (!targetUser) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Prevent changing setup admin role
if (targetUser.isSetupAdmin && role !== 'admin') {
return NextResponse.json(
{ error: 'Cannot change the setup admin role. This account must always remain an admin.' },
{ status: 403 }
);
}
// Update user role
const updatedUser = await prisma.user.update({
where: { id },
data: { role },
select: {
id: true,
plexUsername: true,
role: true,
},
});
return NextResponse.json({ user: updatedUser });
} catch (error) {
console.error('[Admin] Failed to update user:', error);
return NextResponse.json(
{ error: 'Failed to update user' },
{ status: 500 }
);
}
});
});
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Pending Users API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const pendingUsers = await prisma.user.findMany({
where: {
registrationStatus: 'pending_approval'
},
select: {
id: true,
plexUsername: true,
plexEmail: true,
authProvider: true,
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json({ users: pendingUsers });
} catch (error) {
console.error('[Admin] Failed to fetch pending users:', error);
return NextResponse.json(
{ error: 'Failed to fetch pending users' },
{ status: 500 }
);
}
});
});
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Component: Admin Users API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const users = await prisma.user.findMany({
select: {
id: true,
plexId: true,
plexUsername: true,
plexEmail: true,
role: true,
isSetupAdmin: true,
avatarUrl: true,
createdAt: true,
updatedAt: true,
lastLoginAt: true,
_count: {
select: {
requests: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json({ users });
} catch (error) {
console.error('[Admin] Failed to fetch users:', error);
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
);
}
});
});
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Component: Audiobook Details API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
/**
* GET /api/audiobooks/[asin]
* Get detailed information for a specific audiobook
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ asin: string }> }
) {
try {
const { asin } = await params;
if (!asin || asin.length !== 10) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Valid ASIN is required',
},
{ status: 400 }
);
}
const audibleService = getAudibleService();
const audiobook = await audibleService.getAudiobookDetails(asin);
if (!audiobook) {
return NextResponse.json(
{
error: 'NotFound',
message: 'Audiobook not found',
},
{ status: 404 }
);
}
return NextResponse.json({
success: true,
audiobook,
});
} catch (error) {
console.error('Failed to get audiobook details:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch audiobook details',
},
{ status: 500 }
);
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Component: Audiobook Covers API Route
* Documentation: documentation/frontend/pages/login.md
*
* Serves random popular audiobook covers for login page floating animations
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
/**
* GET /api/audiobooks/covers?count=100
* Get random popular audiobook covers for login page
*
* Returns lightweight cover data without matching overhead.
* Returns up to 200 covers for immersive login screen experience.
*/
export async function GET() {
try {
// Fetch all popular audiobooks with covers (up to 200)
const audiobooks = await prisma.audibleCache.findMany({
where: {
isPopular: true,
cachedCoverPath: {
not: null,
},
},
orderBy: {
popularRank: 'asc',
},
take: 200,
select: {
asin: true,
title: true,
author: true,
cachedCoverPath: true,
coverArtUrl: true,
},
});
// Transform to cover URLs
const covers = audiobooks.map((book) => {
// Prefer cached cover, fallback to original URL
let coverUrl = book.coverArtUrl || '';
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
coverUrl,
};
});
// Shuffle for random distribution
const shuffled = covers.sort(() => Math.random() - 0.5);
return NextResponse.json({
success: true,
covers: shuffled,
count: shuffled.length,
});
} catch (error) {
console.error('Failed to get audiobook covers:', error);
// Return empty array on error (login page will show placeholders)
return NextResponse.json({
success: false,
covers: [],
count: 0,
});
}
}
@@ -0,0 +1,140 @@
/**
* Component: New Releases API Route
* Documentation: documentation/integrations/audible.md
*
* Serves new release audiobooks from audible_cache with real-time Plex matching
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
/**
* GET /api/audiobooks/new-releases?page=1&limit=20
* Get new release audiobooks from audible_cache with pagination
*
* Real-time matching against plex_library determines availability.
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Invalid pagination parameters. Page must be >= 1 and limit must be between 1 and 100.',
},
{ status: 400 }
);
}
const skip = (page - 1) * limit;
// Query audible_cache for new release audiobooks
const [audiobooks, totalCount] = await Promise.all([
prisma.audibleCache.findMany({
where: {
isNewRelease: true,
},
orderBy: {
newReleaseRank: 'asc',
},
skip,
take: limit,
select: {
id: true,
asin: true,
title: true,
author: true,
narrator: true,
description: true,
coverArtUrl: true,
cachedCoverPath: true,
durationMinutes: true,
releaseDate: true,
rating: true,
genres: true,
lastSyncedAt: true,
},
}),
prisma.audibleCache.count({
where: {
isNewRelease: true,
},
}),
]);
// If no data found, return helpful message
if (totalCount === 0) {
return NextResponse.json({
success: true,
audiobooks: [],
count: 0,
totalCount: 0,
page,
totalPages: 0,
hasMore: false,
message: 'No new releases found. The Audible data refresh job may need to be run. Please check the Admin Jobs page to enable or trigger the "Audible Data Refresh" job.',
});
}
// Transform to matcher input format (uses ASIN as required field)
// Use cached cover path when available, otherwise fall back to coverArtUrl
const audibleBooks = audiobooks.map((book) => {
// Convert cached path to API URL if it exists
let coverUrl = book.coverArtUrl || undefined;
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
description: book.description || undefined,
coverArtUrl: coverUrl,
durationMinutes: book.durationMinutes || undefined,
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
});
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
totalPages,
hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
});
} catch (error) {
console.error('Failed to get new releases:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch new releases from database',
},
{ status: 500 }
);
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Component: Popular Audiobooks API Route
* Documentation: documentation/integrations/audible.md
*
* Serves popular audiobooks from audible_cache with real-time Plex matching
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
/**
* GET /api/audiobooks/popular?page=1&limit=20
* Get popular audiobooks from audible_cache with pagination
*
* Real-time matching against plex_library determines availability.
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '20', 10);
// Validate pagination parameters
if (page < 1 || limit < 1 || limit > 100) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Invalid pagination parameters. Page must be >= 1 and limit must be between 1 and 100.',
},
{ status: 400 }
);
}
const skip = (page - 1) * limit;
// Query audible_cache for popular audiobooks
const [audiobooks, totalCount] = await Promise.all([
prisma.audibleCache.findMany({
where: {
isPopular: true,
},
orderBy: {
popularRank: 'asc',
},
skip,
take: limit,
select: {
id: true,
asin: true,
title: true,
author: true,
narrator: true,
description: true,
coverArtUrl: true,
cachedCoverPath: true,
durationMinutes: true,
releaseDate: true,
rating: true,
genres: true,
lastSyncedAt: true,
},
}),
prisma.audibleCache.count({
where: {
isPopular: true,
},
}),
]);
// If no data found, return helpful message
if (totalCount === 0) {
return NextResponse.json({
success: true,
audiobooks: [],
count: 0,
totalCount: 0,
page,
totalPages: 0,
hasMore: false,
message: 'No popular audiobooks found. The Audible data refresh job may need to be run. Please check the Admin Jobs page to enable or trigger the "Audible Data Refresh" job.',
});
}
// Transform to matcher input format (uses ASIN as required field)
// Use cached cover path when available, otherwise fall back to coverArtUrl
const audibleBooks = audiobooks.map((book) => {
// Convert cached path to API URL if it exists
let coverUrl = book.coverArtUrl || undefined;
if (book.cachedCoverPath) {
const filename = book.cachedCoverPath.split('/').pop();
coverUrl = `/api/cache/thumbnails/${filename}`;
}
return {
asin: book.asin,
title: book.title,
author: book.author,
narrator: book.narrator || undefined,
description: book.description || undefined,
coverArtUrl: coverUrl,
durationMinutes: book.durationMinutes || undefined,
releaseDate: book.releaseDate?.toISOString() || undefined,
rating: book.rating ? parseFloat(book.rating.toString()) : undefined,
genres: (book.genres as string[]) || [],
};
});
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Enrich with real-time Plex library matching and request status
const enrichedAudiobooks = await enrichAudiobooksWithMatches(audibleBooks, userId);
const totalPages = Math.ceil(totalCount / limit);
const hasMore = page < totalPages;
return NextResponse.json({
success: true,
audiobooks: enrichedAudiobooks,
count: enrichedAudiobooks.length,
totalCount,
page,
totalPages,
hasMore,
lastSync: audiobooks[0]?.lastSyncedAt?.toISOString() || null,
});
} catch (error) {
console.error('Failed to get popular audiobooks:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch popular audiobooks from database',
},
{ status: 500 }
);
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Component: Audiobook Search API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAudibleService } from '@/lib/integrations/audible.service';
import { enrichAudiobooksWithMatches } from '@/lib/utils/audiobook-matcher';
import { getCurrentUser } from '@/lib/middleware/auth';
/**
* GET /api/audiobooks/search?q=query&page=1
* Search for audiobooks on Audible
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q') || searchParams.get('query');
const page = parseInt(searchParams.get('page') || '1', 10);
if (!query) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Search query is required',
},
{ status: 400 }
);
}
const audibleService = getAudibleService();
const results = await audibleService.search(query, page);
// Get current user (optional - for request status enrichment)
const currentUser = getCurrentUser(request);
const userId = currentUser?.sub || undefined;
// Enrich search results with availability and request status information
const enrichedResults = await enrichAudiobooksWithMatches(results.results, userId);
return NextResponse.json({
success: true,
query: results.query,
results: enrichedResults,
totalResults: results.totalResults,
page: results.page,
hasMore: results.hasMore,
});
} catch (error) {
console.error('Failed to search audiobooks:', error);
return NextResponse.json(
{
error: 'SearchError',
message: 'Failed to search audiobooks',
},
{ status: 500 }
);
}
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Component: Local Admin Login Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import bcrypt from 'bcrypt';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getEncryptionService } from '@/lib/services/encryption.service';
/**
* POST /api/auth/admin/login
* Authenticates local admin users with username and password
*/
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Username and password are required',
},
{ status: 400 }
);
}
// Find user by local admin identifier
const user = await prisma.user.findUnique({
where: { plexId: `local-${username}` },
});
if (!user) {
return NextResponse.json(
{
error: 'AuthenticationError',
message: 'Invalid username or password',
},
{ status: 401 }
);
}
// Verify password
// authToken contains an encrypted bcrypt hash, so we need to decrypt it first
let passwordValid = false;
try {
const encryptionService = getEncryptionService();
const decryptedHash = encryptionService.decrypt(user.authToken || '');
passwordValid = await bcrypt.compare(password, decryptedHash);
} catch (error) {
console.error('[AdminLogin] Password verification failed:', error);
return NextResponse.json(
{
error: 'AuthenticationError',
message: 'Invalid username or password',
},
{ status: 401 }
);
}
if (!passwordValid) {
return NextResponse.json(
{
error: 'AuthenticationError',
message: 'Invalid username or password',
},
{ status: 401 }
);
}
// Update last login time
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
// Generate JWT tokens
const accessToken = generateAccessToken({
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
const refreshToken = generateRefreshToken(user.id);
// Return tokens and user info
return NextResponse.json({
success: true,
accessToken,
refreshToken,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
avatarUrl: user.avatarUrl,
},
});
} catch (error) {
console.error('Failed to authenticate admin user:', error);
return NextResponse.json(
{
error: 'AuthenticationError',
message: 'Failed to authenticate',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Component: Check Local Admin Status Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest, isLocalAdmin } from '@/lib/middleware/auth';
/**
* GET /api/auth/is-local-admin
* Check if current authenticated user is a local admin (setup admin)
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
if (!req.user) {
return NextResponse.json(
{
isLocalAdmin: false,
},
{ status: 200 }
);
}
const localAdmin = await isLocalAdmin(req.user.id);
return NextResponse.json({
isLocalAdmin: localAdmin,
});
});
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Local Login Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';
export async function POST(request: NextRequest) {
try {
const { username, password } = await request.json();
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 }
);
}
console.log('[LocalLogin] Attempting login for username:', username);
const provider = new LocalAuthProvider();
const result = await provider.handleCallback({ username, password });
if (!result.success) {
if (result.requiresApproval) {
console.log('[LocalLogin] Account pending approval:', username);
return NextResponse.json({
success: false,
pendingApproval: true,
message: 'Account pending admin approval.',
});
}
console.error('[LocalLogin] Login failed:', result.error);
return NextResponse.json(
{ error: result.error },
{ status: 401 }
);
}
console.log('[LocalLogin] Login successful for:', username);
console.log('[LocalLogin] User data:', result.user);
console.log('[LocalLogin] Token generated successfully');
// Return tokens for login
return NextResponse.json({
success: true,
user: result.user,
accessToken: result.tokens!.accessToken,
refreshToken: result.tokens!.refreshToken,
});
} catch (error) {
console.error('[LocalLogin] Error:', error);
return NextResponse.json(
{ error: 'Login failed' },
{ status: 500 }
);
}
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Component: Logout Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextResponse } from 'next/server';
/**
* POST /api/auth/logout
* Logout user (client-side token clearing, stateless JWT)
*/
export async function POST() {
// Since we're using stateless JWT, logout is primarily client-side
// The client should clear tokens from storage
// TODO: In the future, implement token blacklist for enhanced security
// This would require storing revoked tokens in Redis with expiration
return NextResponse.json({
success: true,
message: 'Logged out successfully',
});
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Component: Current User Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
/**
* GET /api/auth/me
* Get current authenticated user information
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
if (!req.user) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'User not authenticated',
},
{ status: 401 }
);
}
// Fetch full user details from database
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
id: true,
plexId: true,
plexUsername: true,
plexEmail: true,
role: true,
isSetupAdmin: true,
avatarUrl: true,
createdAt: true,
lastLoginAt: true,
},
});
if (!user) {
return NextResponse.json(
{
error: 'NotFound',
message: 'User not found',
},
{ status: 404 }
);
}
// Determine if user is local admin (setup admin with local authentication)
const isLocalAdmin = user.isSetupAdmin && user.plexId.startsWith('local-');
return NextResponse.json({
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
isLocalAdmin: isLocalAdmin,
avatarUrl: user.avatarUrl,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
},
});
});
}
+140
View File
@@ -0,0 +1,140 @@
/**
* OIDC Callback Handler Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getAuthProvider } from '@/lib/services/auth';
import { getBaseUrl } from '@/lib/utils/url';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
const baseUrl = getBaseUrl();
// Handle OAuth errors from provider
if (error) {
const errorMsg = errorDescription || error;
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent(errorMsg)}`);
}
if (!code || !state) {
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent('Missing authorization code or state')}`);
}
try {
// Get OIDC auth provider
const authProvider = await getAuthProvider('oidc');
// Handle callback
const result = await authProvider.handleCallback({ code, state });
if (!result.success) {
// Check if approval is required
if (result.requiresApproval) {
return NextResponse.redirect(`${baseUrl}/login?pending=approval`);
}
// Authentication failed
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent(result.error || 'Authentication failed')}`);
}
// Authentication successful - prepare user data
if (!result.tokens || !result.user) {
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent('Authentication data missing')}`);
}
// Prepare auth data to pass via URL hash (works across all browsers)
const authData = {
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
user: {
id: result.user.id,
plexId: result.user.id, // Use id as plexId for consistency
username: result.user.username,
email: result.user.email,
role: result.user.isAdmin ? 'admin' : 'user',
avatarUrl: result.user.avatarUrl,
},
};
const authDataEncoded = encodeURIComponent(JSON.stringify(authData));
// Prepare user data for cookie
const userDataJson = JSON.stringify(authData.user);
// Determine redirect URL based on first login status
let redirectUrl: string;
if (result.isFirstLogin) {
// First login - redirect to initializing page to show job progress
redirectUrl = `${baseUrl}/setup/initializing#authData=${authDataEncoded}`;
console.log('[OIDC Callback] First login detected - redirecting to initializing page');
} else {
// Normal login - redirect to login page with auth success
redirectUrl = `${baseUrl}/login?auth=success#authData=${authDataEncoded}`;
}
// Return HTML page with cookies set and JavaScript redirect with hash
// This ensures tokens are accessible to frontend via both cookies and URL hash
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Login Successful</title>
</head>
<body>
<p>Login successful. Redirecting...</p>
<script>
// Use JavaScript redirect with hash parameter for compatibility
// Hash params aren't sent to server, so tokens stay client-side
setTimeout(() => {
window.location.href = '${redirectUrl}';
}, 100);
</script>
</body>
</html>
`;
const response = new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html',
},
});
// Set tokens in cookies (httpOnly: false so JavaScript can read them)
response.cookies.set('accessToken', result.tokens.accessToken, {
httpOnly: false, // Need to be accessible to JavaScript
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60, // 1 hour
path: '/',
});
response.cookies.set('refreshToken', result.tokens.refreshToken, {
httpOnly: true, // Keep refresh token secure
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
response.cookies.set('userData', encodeURIComponent(userDataJson), {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60, // 1 hour
path: '/',
});
return response;
} catch (error) {
console.error('[OIDC Callback] Authentication failed:', error);
const errorMsg = error instanceof Error ? error.message : 'Authentication failed';
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent(errorMsg)}`);
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* OIDC Login Initiation Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextResponse } from 'next/server';
import { getAuthProvider } from '@/lib/services/auth';
import { getBaseUrl } from '@/lib/utils/url';
export async function GET() {
try {
// Get OIDC auth provider
const authProvider = await getAuthProvider('oidc');
// Initiate login flow
const { redirectUrl } = await authProvider.initiateLogin();
if (!redirectUrl) {
return NextResponse.json(
{ error: 'Failed to generate authorization URL' },
{ status: 500 }
);
}
// Redirect to OIDC provider
return NextResponse.redirect(redirectUrl);
} catch (error) {
console.error('[OIDC Login] Failed to initiate login:', error);
// Redirect to login page with error
const baseUrl = getBaseUrl();
const errorMessage = error instanceof Error ? error.message : 'Failed to initiate login';
return NextResponse.redirect(`${baseUrl}/login?error=${encodeURIComponent(errorMessage)}`);
}
}
+364
View File
@@ -0,0 +1,364 @@
/**
* Component: Plex OAuth Callback Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { getConfigService } from '@/lib/services/config.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { prisma } from '@/lib/db';
/**
* GET /api/auth/plex/callback?pinId=12345
* Polls Plex PIN status and completes OAuth flow
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const pinId = searchParams.get('pinId');
if (!pinId) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Missing pinId parameter',
},
{ status: 400 }
);
}
const plexService = getPlexService();
const encryptionService = getEncryptionService();
// Check PIN status
const authToken = await plexService.checkPin(parseInt(pinId, 10));
if (!authToken) {
// Still waiting for user to authorize
return NextResponse.json(
{
success: false,
authorized: false,
message: 'Waiting for user authorization',
},
{ status: 202 } // 202 Accepted - still processing
);
}
// Get user info from Plex
const plexUser = await plexService.getUserInfo(authToken);
// Validate user info
if (!plexUser || !plexUser.id) {
console.error('[Plex OAuth] Invalid user info received:', plexUser);
return NextResponse.json(
{
error: 'OAuthError',
message: 'Failed to get user information from Plex',
details: 'User ID is missing from Plex response',
},
{ status: 500 }
);
}
if (!plexUser.username) {
console.error('[Plex OAuth] Username missing from Plex user:', plexUser);
return NextResponse.json(
{
error: 'OAuthError',
message: 'Failed to get user information from Plex',
details: 'Username is missing from Plex response',
},
{ status: 500 }
);
}
// Convert id to string safely
const plexIdString = typeof plexUser.id === 'string' ? plexUser.id : plexUser.id.toString();
// Get configured Plex server settings
const configService = getConfigService();
const plexConfig = await configService.getPlexConfig();
// Verify server is configured
if (!plexConfig.serverUrl || !plexConfig.authToken) {
console.error('[Plex OAuth] Server not configured');
return NextResponse.json(
{
error: 'ConfigurationError',
message: 'Plex server is not configured. Please contact your administrator.',
},
{ status: 503 }
);
}
// Get server machine identifier from stored configuration
// Note: machineIdentifier is stored during setup/settings configuration
const serverMachineId = plexConfig.machineIdentifier;
if (!serverMachineId) {
console.error('[Plex OAuth] machineIdentifier not found in configuration');
return NextResponse.json(
{
error: 'ConfigurationError',
message: 'Server configuration incomplete. Please contact your administrator to re-configure Plex settings.',
},
{ status: 503 }
);
}
console.log('[Plex OAuth] Using stored machineIdentifier:', serverMachineId);
// SECURITY: Verify user has access to the configured Plex server
// This checks if the server appears in the user's list of accessible servers from plex.tv
// This properly validates shared access permissions
const hasAccess = await plexService.verifyServerAccess(
plexConfig.serverUrl,
serverMachineId,
authToken
);
if (!hasAccess) {
console.warn('[Plex OAuth] User attempted to authenticate without server access:', {
plexId: plexIdString,
username: plexUser.username,
serverMachineId,
});
return NextResponse.json(
{
error: 'AccessDenied',
message: 'You do not have access to this Plex server. Please contact the administrator to share their library with you.',
},
{ status: 403 }
);
}
console.log('[Plex OAuth] User verified with server access:', plexUser.username);
// Check for Plex Home profiles
const homeUsers = await plexService.getHomeUsers(authToken);
console.log('[Plex OAuth] Found home users:', homeUsers.length);
// If multiple home users exist, redirect to profile selection
// (Only show selection if there's more than just the main account)
if (homeUsers.length > 1) {
console.log('[Plex OAuth] Account has multiple home profiles, redirecting to profile selection');
// Detect if this is a browser request (mobile redirect) vs AJAX (desktop popup polling)
const accept = request.headers.get('accept') || '';
const isBrowserRequest = accept.includes('text/html');
if (isBrowserRequest) {
// For browser requests (mobile), construct redirect URL with session data
const host = request.headers.get('host') || 'localhost:3030';
const protocol = request.headers.get('x-forwarded-proto') ||
(process.env.NODE_ENV === 'production' ? 'https' : 'http');
const selectProfileUrl = `${protocol}://${host}/auth/select-profile?pinId=${pinId}`;
console.log('[Plex OAuth] Redirecting to profile selection:', selectProfileUrl);
// Return HTML page with JavaScript to store token in sessionStorage and redirect
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Select Profile</title>
</head>
<body>
<p>Loading profiles...</p>
<script>
// Store main account token in session storage for profile selection page
sessionStorage.setItem('plex_main_token', '${authToken}');
// Redirect to profile selection
window.location.href = '${selectProfileUrl}';
</script>
</body>
</html>
`;
return new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html',
},
});
} else {
// For AJAX requests (desktop popup), return JSON with redirect instruction
return NextResponse.json({
success: true,
authorized: true,
requiresProfileSelection: true,
redirectUrl: `/auth/select-profile?pinId=${pinId}`,
mainAccountToken: authToken, // Client will store this temporarily
homeUsers: homeUsers.length,
});
}
}
console.log('[Plex OAuth] Single profile or no additional profiles, continuing with main account authentication');
// No home users - continue with normal authentication flow using main account
// Check if this is the first user (should be promoted to admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
// Create or update user in database
const user = await prisma.user.upsert({
where: { plexId: plexIdString },
create: {
plexId: plexIdString,
plexUsername: plexUser.username,
plexEmail: plexUser.email || null,
role,
avatarUrl: plexUser.thumb || null,
authToken: encryptionService.encrypt(authToken),
lastLoginAt: new Date(),
},
update: {
plexUsername: plexUser.username,
plexEmail: plexUser.email || null,
avatarUrl: plexUser.thumb || null,
authToken: encryptionService.encrypt(authToken),
lastLoginAt: new Date(),
},
});
// Generate JWT tokens
const accessToken = generateAccessToken({
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
const refreshToken = generateRefreshToken(user.id);
// Detect if this is a browser request (mobile redirect) vs AJAX (desktop popup polling)
const accept = request.headers.get('accept') || '';
const isBrowserRequest = accept.includes('text/html');
// For browser requests (mobile), set cookies and redirect to login page
if (isBrowserRequest) {
// Construct the redirect URL from headers (not request.url which may be 0.0.0.0)
const host = request.headers.get('host') || 'localhost:3030';
const protocol = request.headers.get('x-forwarded-proto') ||
(process.env.NODE_ENV === 'production' ? 'https' : 'http');
const redirectUrl = `${protocol}://${host}/login?auth=success`;
console.log('[Plex OAuth] Setting cookies for mobile auth...');
console.log('[Plex OAuth] Redirect URL:', redirectUrl);
// Prepare user data
const userDataJson = JSON.stringify({
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
avatarUrl: user.avatarUrl,
});
console.log('[Plex OAuth] Setting userData cookie:', userDataJson);
// Prepare auth data to pass via URL hash (fallback for mobile browsers that block cookies)
const authData = {
accessToken,
refreshToken,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
avatarUrl: user.avatarUrl,
},
};
const authDataEncoded = encodeURIComponent(JSON.stringify(authData));
// Return HTML page with cookies set and JavaScript redirect with hash
// This ensures cookies are properly set before redirecting
// The hash also provides a fallback for mobile browsers that block cookies on redirects
const html = `
<!DOCTYPE html>
<html>
<head>
<title>Login Successful</title>
</head>
<body>
<p>Login successful. Redirecting...</p>
<script>
// Use JavaScript redirect with hash parameter for mobile compatibility
// Hash params aren't sent to server, so tokens stay client-side
setTimeout(() => {
window.location.href = '${redirectUrl}#authData=${authDataEncoded}';
}, 100);
</script>
</body>
</html>
`;
const response = new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html',
},
});
// Set tokens in cookies
response.cookies.set('accessToken', accessToken, {
httpOnly: false, // Need to be accessible to JavaScript
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60, // 1 hour
path: '/',
});
response.cookies.set('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
response.cookies.set('userData', encodeURIComponent(userDataJson), {
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60, // 1 hour
path: '/',
});
console.log('[Plex OAuth] Cookies set successfully, returning HTML redirect to:', redirectUrl);
return response;
}
// Return tokens and user info (for AJAX requests from desktop popup)
return NextResponse.json({
success: true,
authorized: true,
accessToken,
refreshToken,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
avatarUrl: user.avatarUrl,
},
});
} catch (error) {
console.error('Failed to complete Plex OAuth:', error);
return NextResponse.json(
{
error: 'OAuthError',
message: 'Failed to complete authentication',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Component: Plex Home Users API
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
/**
* GET /api/auth/plex/home-users
* Get list of Plex Home profiles for authenticated user
*/
export async function GET(request: NextRequest) {
try {
const authToken = request.headers.get('X-Plex-Token');
if (!authToken) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Missing authentication token',
},
{ status: 401 }
);
}
const plexService = getPlexService();
const users = await plexService.getHomeUsers(authToken);
return NextResponse.json({
success: true,
users,
});
} catch (error) {
console.error('Failed to get home users:', error);
return NextResponse.json(
{
error: 'ServerError',
message: 'Failed to fetch home users',
},
{ status: 500 }
);
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Component: Plex OAuth Login Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
/**
* POST /api/auth/plex/login
* Initiates Plex OAuth flow by requesting a PIN
*/
export async function POST(request: NextRequest) {
try {
const plexService = getPlexService();
// Request PIN from Plex
const pin = await plexService.requestPin();
// Construct callback URL from the request's origin
// This allows the app to work when accessed via localhost, local IP, or domain
const origin = request.headers.get('origin') || request.headers.get('referer') || 'http://localhost:3030';
const baseUrl = origin.replace(/\/$/, ''); // Remove trailing slash if present
const callbackUrl = `${baseUrl}/api/auth/plex/callback`;
// Generate OAuth URL with pinId
const authUrl = plexService.getOAuthUrl(pin.code, pin.id, callbackUrl);
return NextResponse.json({
success: true,
pinId: pin.id,
code: pin.code,
authUrl,
});
} catch (error) {
console.error('Failed to initiate Plex OAuth:', error);
return NextResponse.json(
{
error: 'OAuthError',
message: 'Failed to initiate Plex authentication',
},
{ status: 500 }
);
}
}
@@ -0,0 +1,180 @@
/**
* Component: Plex Profile Switch API
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { prisma } from '@/lib/db';
/**
* POST /api/auth/plex/switch-profile
* Switch to a Plex Home profile and complete authentication
*/
export async function POST(request: NextRequest) {
try {
const mainAccountToken = request.headers.get('X-Plex-Token');
if (!mainAccountToken) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Missing authentication token',
},
{ status: 401 }
);
}
const body = await request.json();
const { userId, pin, pinId, profileInfo } = body;
if (!userId) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Missing userId',
},
{ status: 400 }
);
}
const plexService = getPlexService();
const encryptionService = getEncryptionService();
// Switch to selected profile
let profileToken: string;
try {
const token = await plexService.switchHomeUser(userId, mainAccountToken, pin);
if (!token) {
throw new Error('Failed to get profile token');
}
profileToken = token;
} catch (error: any) {
if (error.message === 'Invalid PIN') {
return NextResponse.json(
{
error: 'InvalidPIN',
message: 'Invalid PIN for this profile',
},
{ status: 401 }
);
}
throw error;
}
// Use profile info from request (already has all the info from home users list)
// or fall back to getUserInfo for main accounts
let profilePlexId: string;
let profileUsername: string;
let profileEmail: string | null;
let profileThumb: string | null;
if (profileInfo && profileInfo.uuid) {
// Use provided profile info (from home users list - more reliable for managed users)
profilePlexId = profileInfo.uuid;
profileUsername = profileInfo.friendlyName || `User ${userId}`;
profileEmail = profileInfo.email || null;
profileThumb = profileInfo.thumb || null;
console.log('[Profile Switch] Using provided profile info:', {
plexId: profilePlexId,
username: profileUsername,
});
} else {
// Fall back to getUserInfo (for main accounts without profile info)
const profileUser = await plexService.getUserInfo(profileToken);
if (!profileUser || !profileUser.id) {
console.error('[Profile Switch] Failed to get profile user info');
return NextResponse.json(
{
error: 'ServerError',
message: 'Failed to get profile information',
},
{ status: 500 }
);
}
profilePlexId = typeof profileUser.id === 'string' ? profileUser.id : profileUser.id.toString();
profileUsername = profileUser.username || `User ${userId}`;
profileEmail = profileUser.email || null;
profileThumb = profileUser.thumb || null;
console.log('[Profile Switch] Using getUserInfo data:', {
plexId: profilePlexId,
username: profileUsername,
});
}
// Check if this is the first user (should be promoted to admin)
const userCount = await prisma.user.count();
const isFirstUser = userCount === 0;
const role = isFirstUser ? 'admin' : 'user';
// Create or update user with profile details
const user = await prisma.user.upsert({
where: { plexId: profilePlexId },
create: {
plexId: profilePlexId,
plexUsername: profileUsername,
plexEmail: profileEmail,
role,
avatarUrl: profileThumb,
authToken: encryptionService.encrypt(profileToken),
plexHomeUserId: userId, // Store the home user ID
lastLoginAt: new Date(),
},
update: {
plexUsername: profileUsername,
plexEmail: profileEmail,
avatarUrl: profileThumb,
authToken: encryptionService.encrypt(profileToken),
plexHomeUserId: userId, // Update the home user ID
lastLoginAt: new Date(),
},
});
console.log('[Profile Switch] User authenticated:', {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
homeUserId: user.plexHomeUserId,
role: user.role,
});
// Generate JWT tokens
const accessToken = generateAccessToken({
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
const refreshToken = generateRefreshToken(user.id);
// Return tokens and user info
return NextResponse.json({
success: true,
accessToken,
refreshToken,
user: {
id: user.id,
plexId: user.plexId,
username: user.plexUsername,
email: user.plexEmail,
role: user.role,
avatarUrl: user.avatarUrl,
},
});
} catch (error) {
console.error('Failed to switch profile:', error);
return NextResponse.json(
{
error: 'ServerError',
message: 'Failed to switch to selected profile',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* List Available Auth Providers
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextResponse } from 'next/server';
import { ConfigurationService } from '@/lib/services/config.service';
export async function GET() {
try {
const configService = new ConfigurationService();
const backendMode = await configService.get('system.backend_mode');
if (backendMode === 'audiobookshelf') {
// Audiobookshelf mode - check which auth methods are enabled
const oidcEnabled = (await configService.get('oidc.enabled')) === 'true';
const registrationEnabled = (await configService.get('auth.registration_enabled')) === 'true';
const oidcProviderName = await configService.get('oidc.provider_name') || 'SSO';
const providers: string[] = [];
if (oidcEnabled) providers.push('oidc');
if (registrationEnabled) providers.push('local');
return NextResponse.json({
backendMode: 'audiobookshelf',
providers,
registrationEnabled,
oidcProviderName: oidcEnabled ? oidcProviderName : null,
});
} else {
// Plex mode
return NextResponse.json({
backendMode: 'plex',
providers: ['plex'],
registrationEnabled: false,
oidcProviderName: null,
});
}
} catch (error) {
console.error('[Auth] Failed to fetch auth providers:', error);
// Default to Plex mode if config can't be read
return NextResponse.json({
backendMode: 'plex',
providers: ['plex'],
registrationEnabled: false,
oidcProviderName: null,
});
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Component: Token Refresh Route
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyRefreshToken, generateAccessToken } from '@/lib/utils/jwt';
import { prisma } from '@/lib/db';
/**
* POST /api/auth/refresh
* Refresh access token using refresh token
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { refreshToken } = body;
if (!refreshToken) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'Refresh token is required',
},
{ status: 400 }
);
}
// Verify refresh token
const payload = verifyRefreshToken(refreshToken);
if (!payload) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Invalid or expired refresh token',
},
{ status: 401 }
);
}
// Get user from database
const user = await prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user) {
return NextResponse.json(
{
error: 'Unauthorized',
message: 'User not found',
},
{ status: 401 }
);
}
// Generate new access token
const accessToken = generateAccessToken({
sub: user.id,
plexId: user.plexId,
username: user.plexUsername,
role: user.role,
});
return NextResponse.json({
success: true,
accessToken,
expiresIn: 3600, // 1 hour in seconds
});
} catch (error) {
console.error('Failed to refresh token:', error);
return NextResponse.json(
{
error: 'RefreshError',
message: 'Failed to refresh access token',
},
{ status: 500 }
);
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* User Registration Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { LocalAuthProvider } from '@/lib/services/auth/LocalAuthProvider';
// Rate limiting map (in production, use Redis)
const registrationAttempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const attempts = registrationAttempts.get(ip);
if (!attempts || now > attempts.resetAt) {
registrationAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
return true;
}
if (attempts.count >= MAX_ATTEMPTS) {
return false;
}
attempts.count++;
return true;
}
export async function POST(request: NextRequest) {
// Rate limiting
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return NextResponse.json(
{ error: 'Too many registration attempts. Please try again later.' },
{ status: 429 }
);
}
try {
const { username, password } = await request.json();
const provider = new LocalAuthProvider();
const result = await provider.register({ username, password });
if (!result.success) {
if (result.requiresApproval) {
return NextResponse.json({
success: false,
pendingApproval: true,
message: 'Account created. Waiting for admin approval.',
});
}
return NextResponse.json(
{ error: result.error },
{ status: 400 }
);
}
// Return tokens for auto-login
return NextResponse.json({
success: true,
user: result.user,
accessToken: result.tokens!.accessToken,
refreshToken: result.tokens!.refreshToken,
});
} catch (error) {
console.error('[Registration] Error:', error);
return NextResponse.json(
{ error: 'Registration failed' },
{ status: 500 }
);
}
}
+183
View File
@@ -0,0 +1,183 @@
/**
* BookDate: User Configuration Management
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getEncryptionService } from '@/lib/services/encryption.service';
// GET: Fetch global BookDate configuration (excluding API key)
// Any authenticated user can check if BookDate is configured
async function getConfig(req: AuthenticatedRequest) {
try {
// Get the single global config (there should only be one record)
const config = await prisma.bookDateConfig.findFirst();
if (!config) {
return NextResponse.json({ config: null });
}
// Don't return API key for security
const { apiKey, ...safeConfig } = config;
return NextResponse.json({ config: safeConfig });
} catch (error: any) {
console.error('[BookDate] Get config error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to fetch configuration' },
{ status: 500 }
);
}
}
// POST: Create or update global BookDate configuration (Admin only)
async function saveConfig(req: AuthenticatedRequest) {
try {
const body = await req.json();
const { provider, apiKey, model, libraryScope, customPrompt, isEnabled } = body;
// Check if config exists
const existingConfig = await prisma.bookDateConfig.findFirst();
// Validation - API key only required for new configs
if (!existingConfig && !apiKey) {
return NextResponse.json(
{ error: 'API key is required for initial setup' },
{ status: 400 }
);
}
if (!provider || !model) {
return NextResponse.json(
{ error: 'Missing required fields: provider, model' },
{ status: 400 }
);
}
if (!['openai', 'claude'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai" or "claude"' },
{ status: 400 }
);
}
// Determine which API key to use
let encryptedApiKeyToUse: string;
if (apiKey) {
// New API key provided - encrypt it
const encryptionService = getEncryptionService();
encryptedApiKeyToUse = encryptionService.encrypt(apiKey);
} else if (existingConfig) {
// No new API key, use existing one
encryptedApiKeyToUse = existingConfig.apiKey;
} else {
// This shouldn't happen due to validation above, but just in case
return NextResponse.json(
{ error: 'API key is required for new configuration' },
{ status: 400 }
);
}
let config;
if (existingConfig) {
// Update existing config
const updateData: any = {
provider,
model,
isEnabled: isEnabled !== undefined ? isEnabled : true,
isVerified: true,
updatedAt: new Date(),
};
// Only update API key if a new one was provided
if (apiKey) {
updateData.apiKey = encryptedApiKeyToUse;
}
config = await prisma.bookDateConfig.update({
where: { id: existingConfig.id },
data: updateData,
});
} else {
// Create new global config
// Note: libraryScope and customPrompt are now per-user settings (deprecated in global config)
config = await prisma.bookDateConfig.create({
data: {
provider,
model,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isEnabled: isEnabled !== undefined ? isEnabled : true,
isVerified: true,
apiKey: encryptedApiKeyToUse,
},
});
}
// Clear ALL users' cached recommendations when global config changes
await prisma.bookDateRecommendation.deleteMany({});
// Return config without API key
const { apiKey: _, ...safeConfig } = config;
return NextResponse.json({
success: true,
config: safeConfig,
});
} catch (error: any) {
console.error('[BookDate] Save config error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to save configuration' },
{ status: 500 }
);
}
}
// DELETE: Remove global BookDate configuration (Admin only)
async function deleteConfig(req: AuthenticatedRequest) {
try {
// Get the global config
const config = await prisma.bookDateConfig.findFirst();
if (!config) {
return NextResponse.json(
{ error: 'Configuration not found' },
{ status: 404 }
);
}
// Delete global configuration
await prisma.bookDateConfig.delete({
where: { id: config.id },
});
// Also delete ALL cached recommendations and swipe history
await prisma.bookDateRecommendation.deleteMany({});
await prisma.bookDateSwipe.deleteMany({});
return NextResponse.json({ success: true });
} catch (error: any) {
console.error('[BookDate] Delete config error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to delete configuration' },
{ status: 500 }
);
}
}
export async function GET(req: NextRequest) {
return requireAuth(req, getConfig);
}
export async function POST(req: NextRequest) {
return requireAuth(req, async (authReq) => requireAdmin(authReq, saveConfig));
}
export async function DELETE(req: NextRequest) {
return requireAuth(req, async (authReq) => requireAdmin(authReq, deleteConfig));
}
+179
View File
@@ -0,0 +1,179 @@
/**
* BookDate: Force Generate New Recommendations
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import {
buildAIPrompt,
callAI,
matchToAudnexus,
isInLibrary,
isAlreadyRequested,
isAlreadySwiped,
} from '@/lib/bookdate/helpers';
async function handler(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Get global config
const config = await prisma.bookDateConfig.findFirst();
if (!config || !config.isVerified || !config.isEnabled) {
return NextResponse.json(
{
error: 'BookDate is not configured or has been disabled. Please contact your administrator.',
},
{ status: 400 }
);
}
// Get user's preferences
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
bookDateLibraryScope: true,
bookDateCustomPrompt: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Build user preferences object
const userPreferences = {
libraryScope: user.bookDateLibraryScope || 'full',
customPrompt: user.bookDateCustomPrompt || null,
};
// Build prompt and call AI (same as recommendations endpoint, but doesn't check cache)
console.log('[BookDate] Force generating new recommendations for user:', userId);
const prompt = await buildAIPrompt(userId, userPreferences);
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt);
if (!aiResponse.recommendations || !Array.isArray(aiResponse.recommendations)) {
throw new Error('Invalid AI response format: missing recommendations array');
}
console.log(`[BookDate] AI returned ${aiResponse.recommendations.length} recommendations`);
// Match to Audnexus and filter
const batchId = `batch_${Date.now()}`;
const matched: any[] = [];
for (const rec of aiResponse.recommendations) {
if (!rec.title || !rec.author) {
continue;
}
// Check if already swiped
if (await isAlreadySwiped(userId, rec.title, rec.author)) {
continue;
}
// Check if in library
if (await isInLibrary(userId, rec.title, rec.author)) {
continue;
}
// Match to Audnexus
try {
const audnexusMatch = await matchToAudnexus(rec.title, rec.author);
if (!audnexusMatch) {
console.warn(`[BookDate] No Audnexus match: "${rec.title}" by ${rec.author}`);
continue;
}
// Check again if in library with ASIN for exact matching
// This catches books that might have different titles (e.g., "The Tenant" vs "The Tenant (Unabridged)")
if (await isInLibrary(userId, audnexusMatch.title, audnexusMatch.author, audnexusMatch.asin)) {
console.log(`[BookDate] Book "${audnexusMatch.title}" (ASIN: ${audnexusMatch.asin}) is in library, skipping`);
continue;
}
// Check if already requested
if (await isAlreadyRequested(userId, audnexusMatch.asin)) {
continue;
}
matched.push({
userId,
batchId,
title: audnexusMatch.title,
author: audnexusMatch.author,
narrator: audnexusMatch.narrator,
rating: audnexusMatch.rating,
description: audnexusMatch.description,
coverUrl: audnexusMatch.coverUrl,
audnexusAsin: audnexusMatch.asin,
aiReason: rec.reason || 'Recommended based on your preferences',
});
if (matched.length >= 10) {
break;
}
} catch (error) {
console.warn(`[BookDate] Match error for "${rec.title}":`, error);
continue;
}
}
console.log(`[BookDate] Matched ${matched.length} new recommendations`);
if (matched.length === 0) {
return NextResponse.json(
{
error: 'Could not find any new recommendations. Try adjusting your settings or check back later.',
},
{ status: 404 }
);
}
// Save to database
await prisma.bookDateRecommendation.createMany({
data: matched,
});
// Return all cached recommendations (excluding swiped ones)
const allRecommendations = await prisma.bookDateRecommendation.findMany({
where: {
userId,
// Exclude recommendations that have associated swipes
swipes: {
none: {},
},
},
orderBy: { createdAt: 'asc' },
take: 10,
});
return NextResponse.json({
recommendations: allRecommendations,
source: 'generated',
generatedCount: matched.length,
});
} catch (error: any) {
console.error('[BookDate] Generate error:', error);
return NextResponse.json(
{
error: error.message || 'Failed to generate new recommendations',
details: process.env.NODE_ENV === 'development' ? error.stack : undefined,
},
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return requireAuth(req, handler);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Component: BookDate User Preferences API
* Documentation: documentation/features/bookdate.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
/**
* GET /api/bookdate/preferences
* Get current user's BookDate preferences (library scope and custom prompt)
*/
async function getPreferences(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Get user preferences
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
bookDateLibraryScope: true,
bookDateCustomPrompt: true,
bookDateOnboardingComplete: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json({
libraryScope: user.bookDateLibraryScope || 'full',
customPrompt: user.bookDateCustomPrompt || '', // Always return empty string for UI
onboardingComplete: user.bookDateOnboardingComplete || false,
});
} catch (error: any) {
console.error('Get BookDate preferences error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to get preferences' },
{ status: 500 }
);
}
}
/**
* PUT /api/bookdate/preferences
* Update current user's BookDate preferences
*/
async function updatePreferences(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Parse request body
const body = await req.json();
const { libraryScope, customPrompt, onboardingComplete } = body;
// Validate library scope
if (libraryScope && !['full', 'rated'].includes(libraryScope)) {
return NextResponse.json(
{ error: 'Invalid library scope. Must be "full" or "rated"' },
{ status: 400 }
);
}
// Validate custom prompt length (only if provided and not empty)
if (customPrompt && typeof customPrompt === 'string' && customPrompt.trim() && customPrompt.length > 1000) {
return NextResponse.json(
{ error: 'Custom prompt must be 1000 characters or less' },
{ status: 400 }
);
}
// Build update data object
const updateData: any = {};
if (libraryScope !== undefined) {
updateData.bookDateLibraryScope = libraryScope || 'full';
}
if (customPrompt !== undefined) {
// Normalize empty strings to null for consistency
const normalizedPrompt = (typeof customPrompt === 'string' && customPrompt.trim()) ? customPrompt.trim() : null;
updateData.bookDateCustomPrompt = normalizedPrompt;
}
if (onboardingComplete !== undefined) {
updateData.bookDateOnboardingComplete = onboardingComplete;
}
// Update user preferences
const updatedUser = await prisma.user.update({
where: { id: userId },
data: updateData,
select: {
bookDateLibraryScope: true,
bookDateCustomPrompt: true,
bookDateOnboardingComplete: true,
},
});
return NextResponse.json({
success: true,
libraryScope: updatedUser.bookDateLibraryScope || 'full',
customPrompt: updatedUser.bookDateCustomPrompt || '', // Always return empty string for UI
onboardingComplete: updatedUser.bookDateOnboardingComplete || false,
});
} catch (error: any) {
console.error('Update BookDate preferences error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to update preferences' },
{ status: 500 }
);
}
}
export async function GET(req: NextRequest) {
return requireAuth(req, getPreferences);
}
export async function PUT(req: NextRequest) {
return requireAuth(req, updatePreferences);
}
@@ -0,0 +1,196 @@
/**
* BookDate: Get Recommendations
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import {
buildAIPrompt,
callAI,
matchToAudnexus,
isInLibrary,
isAlreadyRequested,
isAlreadySwiped,
} from '@/lib/bookdate/helpers';
async function handler(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Check for cached recommendations (exclude any that have been swiped)
const cached = await prisma.bookDateRecommendation.findMany({
where: {
userId,
// Exclude recommendations that have associated swipes
swipes: {
none: {},
},
},
orderBy: { createdAt: 'asc' },
});
// If there are any cached unswiped recommendations, return them
if (cached.length > 0) {
return NextResponse.json({
recommendations: cached,
source: 'cache',
remaining: cached.length,
});
}
// Need to generate new recommendations - fetch global config
const config = await prisma.bookDateConfig.findFirst();
if (!config || !config.isVerified || !config.isEnabled) {
return NextResponse.json(
{
error: 'BookDate is not configured or has been disabled. Please contact your administrator.',
},
{ status: 400 }
);
}
// Get user's preferences
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
bookDateLibraryScope: true,
bookDateCustomPrompt: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Build user preferences object
const userPreferences = {
libraryScope: user.bookDateLibraryScope || 'full',
customPrompt: user.bookDateCustomPrompt || null,
};
// Build prompt and call AI
console.log('[BookDate] Generating new recommendations for user:', userId);
const prompt = await buildAIPrompt(userId, userPreferences);
const aiResponse = await callAI(config.provider, config.model, config.apiKey, prompt);
if (!aiResponse.recommendations || !Array.isArray(aiResponse.recommendations)) {
throw new Error('Invalid AI response format: missing recommendations array');
}
console.log(`[BookDate] AI returned ${aiResponse.recommendations.length} recommendations`);
// Match to Audnexus and filter
const batchId = `batch_${Date.now()}`;
const matched: any[] = [];
for (const rec of aiResponse.recommendations) {
if (!rec.title || !rec.author) {
console.warn('[BookDate] Skipping recommendation with missing title or author');
continue;
}
// Check if already swiped
if (await isAlreadySwiped(userId, rec.title, rec.author)) {
console.log(`[BookDate] Skipping already swiped: "${rec.title}"`);
continue;
}
// Check if in library
if (await isInLibrary(userId, rec.title, rec.author)) {
console.log(`[BookDate] Skipping already in library: "${rec.title}"`);
continue;
}
// Match to Audnexus
try {
const audnexusMatch = await matchToAudnexus(rec.title, rec.author);
if (!audnexusMatch) {
console.warn(`[BookDate] No Audnexus match: "${rec.title}" by ${rec.author}`);
continue;
}
// Check again if in library with ASIN for exact matching
// This catches books that might have different titles (e.g., "The Tenant" vs "The Tenant (Unabridged)")
if (await isInLibrary(userId, audnexusMatch.title, audnexusMatch.author, audnexusMatch.asin)) {
console.log(`[BookDate] Book "${audnexusMatch.title}" (ASIN: ${audnexusMatch.asin}) is in library, skipping`);
continue;
}
// Check if already requested
if (await isAlreadyRequested(userId, audnexusMatch.asin)) {
console.log(`[BookDate] Skipping already requested: "${rec.title}"`);
continue;
}
matched.push({
userId,
batchId,
title: audnexusMatch.title,
author: audnexusMatch.author,
narrator: audnexusMatch.narrator,
rating: audnexusMatch.rating,
description: audnexusMatch.description,
coverUrl: audnexusMatch.coverUrl,
audnexusAsin: audnexusMatch.asin,
aiReason: rec.reason || 'Recommended based on your preferences',
});
if (matched.length >= 10) {
break;
}
} catch (error) {
console.warn(`[BookDate] Match error for "${rec.title}":`, error);
continue;
}
}
console.log(`[BookDate] Matched ${matched.length} recommendations`);
// Save to database
if (matched.length > 0) {
await prisma.bookDateRecommendation.createMany({
data: matched,
});
}
// Combine with existing cache (exclude swiped recommendations)
const allRecommendations = await prisma.bookDateRecommendation.findMany({
where: {
userId,
swipes: {
none: {},
},
},
orderBy: { createdAt: 'asc' },
take: 10,
});
return NextResponse.json({
recommendations: allRecommendations,
source: 'generated',
generatedCount: matched.length,
});
} catch (error: any) {
console.error('[BookDate] Recommendations error:', error);
return NextResponse.json(
{
error: error.message || 'Failed to generate recommendations',
details: process.env.NODE_ENV === 'development' ? error.stack : undefined,
},
{ status: 500 }
);
}
}
export async function GET(req: NextRequest) {
return requireAuth(req, handler);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* BookDate: Record Swipe Action
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
async function handler(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
const body = await req.json();
const { recommendationId, action, markedAsKnown } = body;
// Validation
if (!recommendationId || !action) {
return NextResponse.json(
{ error: 'recommendationId and action are required' },
{ status: 400 }
);
}
if (!['left', 'right', 'up'].includes(action)) {
return NextResponse.json(
{ error: 'Invalid action. Must be "left", "right", or "up"' },
{ status: 400 }
);
}
// Get recommendation
const recommendation = await prisma.bookDateRecommendation.findUnique({
where: { id: recommendationId },
});
if (!recommendation || recommendation.userId !== userId) {
return NextResponse.json(
{ error: 'Recommendation not found or does not belong to user' },
{ status: 404 }
);
}
// Record swipe (keep recommendation in database for undo functionality)
await prisma.bookDateSwipe.create({
data: {
userId,
recommendationId,
bookTitle: recommendation.title,
bookAuthor: recommendation.author,
action,
markedAsKnown: markedAsKnown || false,
},
});
// NOTE: We no longer delete the recommendation here.
// This allows undo to work properly by keeping all the original data.
// The recommendations endpoint filters out swiped cards.
// If swiped right and not marked as known, create request
if (action === 'right' && !markedAsKnown && recommendation.audnexusAsin) {
try {
// Check if book already exists in audiobooks table
let audiobook = await prisma.audiobook.findFirst({
where: { audibleAsin: recommendation.audnexusAsin },
});
// If not, create it
if (!audiobook) {
audiobook = await prisma.audiobook.create({
data: {
audibleAsin: recommendation.audnexusAsin,
title: recommendation.title,
author: recommendation.author,
narrator: recommendation.narrator,
description: recommendation.description,
coverArtUrl: recommendation.coverUrl,
status: 'requested',
},
});
}
// Create request (if not already exists)
const existingRequest = await prisma.request.findFirst({
where: {
userId,
audiobookId: audiobook.id,
},
});
if (!existingRequest) {
const newRequest = await prisma.request.create({
data: {
userId,
audiobookId: audiobook.id,
status: 'pending',
priority: 0,
},
});
console.log(`[BookDate] Created request for "${recommendation.title}"`);
// Trigger search job (same as regular request creation)
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(newRequest.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
});
console.log(`[BookDate] Triggered search job for request ${newRequest.id}`);
}
} catch (error) {
console.error('[BookDate] Error creating request:', error);
// Don't fail the swipe if request creation fails
}
}
return NextResponse.json({
success: true,
action,
markedAsKnown,
});
} catch (error: any) {
console.error('[BookDate] Swipe error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to record swipe' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return requireAuth(req, handler);
}
+37
View File
@@ -0,0 +1,37 @@
/**
* BookDate: Clear Swipe History (Admin Only)
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
// DELETE: Clear all users' swipe history (Admin only)
async function clearSwipes(req: AuthenticatedRequest) {
try {
// Delete all swipes for ALL users (global admin action)
await prisma.bookDateSwipe.deleteMany({});
// Also clear all cached recommendations (since swipe history affects recommendations)
await prisma.bookDateRecommendation.deleteMany({});
console.log('[BookDate] Admin cleared all swipe history and recommendations');
return NextResponse.json({
success: true,
message: 'All swipe history cleared',
});
} catch (error: any) {
console.error('[BookDate] Clear swipes error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to clear swipe history' },
{ status: 500 }
);
}
}
export async function DELETE(req: NextRequest) {
return requireAuth(req, async (authReq) => requireAdmin(authReq, clearSwipes));
}
@@ -0,0 +1,260 @@
/**
* BookDate: Test AI Provider Connection & Fetch Models
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
async function authenticatedHandler(req: AuthenticatedRequest) {
try {
const body = await req.json();
const { provider, apiKey, useSavedKey } = body;
// Validate provider
if (!provider) {
return NextResponse.json(
{ error: 'Provider is required' },
{ status: 400 }
);
}
if (!['openai', 'claude'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai" or "claude"' },
{ status: 400 }
);
}
// Get API key from saved global config if useSavedKey is true
let testApiKey = apiKey;
if (useSavedKey && !testApiKey) {
const { prisma } = await import('@/lib/db');
const { getEncryptionService } = await import('@/lib/services/encryption.service');
const config = await prisma.bookDateConfig.findFirst();
if (!config || !config.apiKey) {
return NextResponse.json(
{ error: 'No saved API key found' },
{ status: 400 }
);
}
const encryptionService = getEncryptionService();
testApiKey = encryptionService.decrypt(config.apiKey);
}
if (!testApiKey) {
return NextResponse.json(
{ error: 'API key is required' },
{ status: 400 }
);
}
let models = [];
if (provider === 'openai') {
// OpenAI: Fetch models from API
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${testApiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('[BookDate] OpenAI API error:', errorText);
return NextResponse.json(
{ error: 'Invalid OpenAI API key or connection failed' },
{ status: 400 }
);
}
const data = await response.json();
// Filter to relevant GPT models
models = data.data
.filter((m: any) => m.id.startsWith('gpt-') && m.id.includes('4'))
.map((m: any) => ({
id: m.id,
name: m.id,
}))
.sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
models = [
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
];
// Test connection with a simple API call
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': testApiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 10,
messages: [{ role: 'user', content: 'Test' }],
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[BookDate] Claude API error:', errorText);
return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' },
{ status: 400 }
);
}
}
return NextResponse.json({
success: true,
models,
provider,
});
} catch (error: any) {
console.error('[BookDate] Test connection error:', error);
return NextResponse.json(
{ error: error.message || 'Connection test failed' },
{ status: 500 }
);
}
}
// Unauthenticated handler for setup wizard
async function unauthenticatedHandler(req: NextRequest) {
try {
const body = await req.json();
const { provider, apiKey, useSavedKey } = body;
// During setup, useSavedKey should not be used (no auth context)
if (useSavedKey) {
return NextResponse.json(
{ error: 'Authentication required to use saved API key' },
{ status: 401 }
);
}
// Validate provider
if (!provider) {
return NextResponse.json(
{ error: 'Provider is required' },
{ status: 400 }
);
}
if (!['openai', 'claude'].includes(provider)) {
return NextResponse.json(
{ error: 'Invalid provider. Must be "openai" or "claude"' },
{ status: 400 }
);
}
if (!apiKey) {
return NextResponse.json(
{ error: 'API key is required' },
{ status: 400 }
);
}
let models = [];
if (provider === 'openai') {
// OpenAI: Fetch models from API
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('[BookDate] OpenAI API error:', errorText);
return NextResponse.json(
{ error: 'Invalid OpenAI API key or connection failed' },
{ status: 400 }
);
}
const data = await response.json();
// Filter to relevant GPT models
models = data.data
.filter((m: any) => m.id.startsWith('gpt-') && m.id.includes('4'))
.map((m: any) => ({
id: m.id,
name: m.id,
}))
.sort((a: any, b: any) => a.name.localeCompare(b.name));
} else if (provider === 'claude') {
// Claude: Hardcoded list (Anthropic doesn't have a public models API endpoint)
models = [
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5 (Latest)' },
{ id: 'claude-3-7-sonnet-20250219', name: 'Claude 3.7 Sonnet' },
{ id: 'claude-opus-4-20250514', name: 'Claude Opus 4' },
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku' },
];
// Test connection with a simple API call
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-haiku-20241022',
max_tokens: 10,
messages: [{ role: 'user', content: 'Test' }],
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error('[BookDate] Claude API error:', errorText);
return NextResponse.json(
{ error: 'Invalid Claude API key or connection failed' },
{ status: 400 }
);
}
}
return NextResponse.json({
success: true,
models,
provider,
});
} catch (error: any) {
console.error('[BookDate] Test connection error:', error);
return NextResponse.json(
{ error: error.message || 'Connection test failed' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
// Check if request has authorization header
const authHeader = req.headers.get('authorization');
if (authHeader) {
// Authenticated request (from settings page)
return requireAuth(req, authenticatedHandler);
} else {
// Unauthenticated request (from setup wizard)
return unauthenticatedHandler(req);
}
}
+90
View File
@@ -0,0 +1,90 @@
/**
* BookDate: Undo Last Swipe
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
async function handler(req: AuthenticatedRequest) {
try {
const userId = req.user!.id;
// Get last swipe (left or up only - can't undo right swipes)
const lastSwipe = await prisma.bookDateSwipe.findFirst({
where: {
userId,
action: {
in: ['left', 'up'],
},
},
orderBy: {
createdAt: 'desc',
},
include: {
recommendation: true,
},
});
if (!lastSwipe) {
return NextResponse.json(
{ error: 'No swipe to undo' },
{ status: 404 }
);
}
if (!lastSwipe.recommendation) {
return NextResponse.json(
{ error: 'Recommendation no longer exists' },
{ status: 404 }
);
}
// Find the oldest existing unswiped recommendation to determine where to insert
const oldestRecommendation = await prisma.bookDateRecommendation.findFirst({
where: {
userId,
swipes: {
none: {},
},
},
orderBy: { createdAt: 'asc' },
});
// Set createdAt to be before the oldest recommendation (so it appears at the front)
// If no recommendations exist, set it to 1 day ago
const undoCreatedAt = oldestRecommendation
? new Date(oldestRecommendation.createdAt.getTime() - 1000) // 1 second before oldest
: new Date(Date.now() - 24 * 60 * 60 * 1000); // 1 day ago if none exist
// Delete the swipe (this makes the recommendation visible again)
await prisma.bookDateSwipe.delete({
where: { id: lastSwipe.id },
});
// Update the recommendation's createdAt to put it at the front of the stack
const restoredRecommendation = await prisma.bookDateRecommendation.update({
where: { id: lastSwipe.recommendation.id },
data: {
createdAt: undoCreatedAt,
},
});
return NextResponse.json({
success: true,
recommendation: restoredRecommendation,
});
} catch (error: any) {
console.error('[BookDate] Undo error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to undo swipe' },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
return requireAuth(req, handler);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Component: Thumbnail Cache API Route
* Documentation: documentation/integrations/audible.md
*/
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
const CACHE_DIR = '/app/cache/thumbnails';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
const { filename } = await params;
// Validate filename (prevent directory traversal)
if (!filename || filename.includes('..') || filename.includes('/')) {
return NextResponse.json(
{ error: 'Invalid filename' },
{ status: 400 }
);
}
const filePath = path.join(CACHE_DIR, filename);
// Check if file exists
try {
await fs.access(filePath);
} catch {
return NextResponse.json(
{ error: 'File not found' },
{ status: 404 }
);
}
// Read the file
const fileBuffer = await fs.readFile(filePath);
// Determine content type based on extension
const ext = path.extname(filename).toLowerCase();
const contentTypeMap: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
const contentType = contentTypeMap[ext] || 'application/octet-stream';
// Return the image with appropriate headers
return new NextResponse(fileBuffer, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
},
});
} catch (error) {
console.error('[ThumbnailAPI] Error serving thumbnail:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Component: Configuration API Routes (by category)
* Documentation: documentation/backend/services/config.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getConfigService } from '@/lib/services/config.service';
// GET /api/config/:category - Get all config for a category
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ category: string }> }
) {
try {
// TODO: Add authentication middleware - admin only
const { category } = await params;
const configService = getConfigService();
const config = await configService.getCategory(category);
return NextResponse.json({
category,
config,
});
} catch (error) {
console.error(`Failed to get config for category:`, error);
return NextResponse.json(
{
error: 'Failed to get configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Component: Configuration API Routes
* Documentation: documentation/backend/services/config.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getConfigService, ConfigUpdate } from '@/lib/services/config.service';
import { z } from 'zod';
const ConfigUpdateSchema = z.object({
updates: z.array(
z.object({
key: z.string(),
value: z.string(),
encrypted: z.boolean().optional(),
category: z.string().optional(),
description: z.string().optional(),
})
),
});
// PUT /api/config - Update multiple configuration values
export async function PUT(request: NextRequest) {
try {
// TODO: Add authentication middleware - admin only
const body = await request.json();
const { updates } = ConfigUpdateSchema.parse(body);
const configService = getConfigService();
await configService.setMany(updates as ConfigUpdate[]);
return NextResponse.json({
success: true,
updated: updates.length,
});
} catch (error) {
console.error('Failed to update configuration:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'Validation error',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'Failed to update configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
// GET /api/config - Get all configuration (masked sensitive values)
export async function GET() {
try {
// TODO: Add authentication middleware - admin only
const configService = getConfigService();
const allConfig = await configService.getAll();
return NextResponse.json({
config: allConfig,
});
} catch (error) {
console.error('Failed to get all configuration:', error);
return NextResponse.json(
{
error: 'Failed to get configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Component: Health Check API Route
* Documentation: documentation/deployment/docker.md
*/
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
export async function GET() {
try {
// Check database connectivity
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
status: 'healthy',
timestamp: new Date().toISOString(),
database: 'connected',
});
} catch (error) {
console.error('Health check failed:', error);
return NextResponse.json(
{
status: 'unhealthy',
timestamp: new Date().toISOString(),
database: 'disconnected',
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 503 }
);
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Component: Initialization API Route
* Documentation: documentation/backend/services/scheduler.md
*
* This route is called during server startup to initialize the scheduler
* and trigger any overdue jobs.
*/
import { NextRequest, NextResponse } from 'next/server';
import { getSchedulerService } from '@/lib/services/scheduler.service';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
try {
console.log('[Init] Initializing application services...');
// Initialize scheduler service
const schedulerService = getSchedulerService();
await schedulerService.start();
console.log('[Init] Application services initialized successfully');
return NextResponse.json({
success: true,
message: 'Application services initialized',
});
} catch (error) {
console.error('[Init] Failed to initialize services:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to initialize services',
},
{ status: 500 }
);
}
}
@@ -0,0 +1,99 @@
/**
* Component: Interactive Search API
* Documentation: documentation/phase3/prowlarr.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { rankTorrents } from '@/lib/utils/ranking-algorithm';
/**
* POST /api/requests/[id]/interactive-search
* Search for torrents and return results for user selection
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const requestRecord = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
},
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Check authorization
if (requestRecord.userId !== req.user.id && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have access to this request' },
{ status: 403 }
);
}
// Search Prowlarr for torrents
const prowlarr = await getProwlarrService();
const searchQuery = `${requestRecord.audiobook.title} ${requestRecord.audiobook.author}`;
console.log(`[InteractiveSearch] Searching for: ${searchQuery}`);
const results = await prowlarr.search(searchQuery);
if (results.length === 0) {
return NextResponse.json({
success: true,
results: [],
message: 'No torrents found',
});
}
// Rank torrents using the ranking algorithm
const rankedResults = rankTorrents(results, {
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
});
// Add rank position to each result
const resultsWithRank = rankedResults.map((result, index) => ({
...result,
rank: index + 1,
}));
console.log(`[InteractiveSearch] Found ${resultsWithRank.length} results for request ${id}`);
return NextResponse.json({
success: true,
results: resultsWithRank,
message: `Found ${resultsWithRank.length} torrents`,
});
} catch (error) {
console.error('Failed to perform interactive search:', error);
return NextResponse.json(
{
error: 'SearchError',
message: error instanceof Error ? error.message : 'Failed to search for torrents',
},
{ status: 500 }
);
}
});
}
@@ -0,0 +1,102 @@
/**
* Component: Manual Search API
* Documentation: documentation/phase3/prowlarr.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
/**
* POST /api/requests/[id]/manual-search
* Manually trigger a search for torrents
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const requestRecord = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
},
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Check authorization
if (requestRecord.userId !== req.user.id && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have access to this request' },
{ status: 403 }
);
}
// Only allow manual search for pending, failed, awaiting_search statuses
const searchableStatuses = ['pending', 'failed', 'awaiting_search'];
if (!searchableStatuses.includes(requestRecord.status)) {
return NextResponse.json(
{
error: 'ValidationError',
message: `Cannot manually search for request with status: ${requestRecord.status}`,
},
{ status: 400 }
);
}
// Trigger search job
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(id, {
id: requestRecord.audiobook.id,
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
});
// Update request status
const updated = await prisma.request.update({
where: { id },
data: {
status: 'pending',
progress: 0,
errorMessage: null,
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
return NextResponse.json({
success: true,
request: updated,
message: 'Manual search initiated',
});
} catch (error) {
console.error('Failed to trigger manual search:', error);
return NextResponse.json(
{
error: 'SearchError',
message: 'Failed to initiate manual search',
},
{ status: 500 }
);
}
});
}
+325
View File
@@ -0,0 +1,325 @@
/**
* Component: Individual Request API Routes
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
/**
* GET /api/requests/[id]
* Get a specific request by ID
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const requestRecord = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
downloadHistory: {
where: { selected: true },
take: 1,
},
jobs: {
orderBy: { createdAt: 'desc' },
take: 5,
},
},
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Check authorization: users can only see their own requests, admins can see all
if (requestRecord.userId !== req.user.id && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have access to this request' },
{ status: 403 }
);
}
return NextResponse.json({
success: true,
request: requestRecord,
});
} catch (error) {
console.error('Failed to get request:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch request',
},
{ status: 500 }
);
}
});
}
/**
* PATCH /api/requests/[id]
* Update a request (cancel, retry, etc.)
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const body = await req.json();
const { action } = body;
const requestRecord = await prisma.request.findUnique({
where: { id },
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Check authorization
if (requestRecord.userId !== req.user.id && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have access to this request' },
{ status: 403 }
);
}
if (action === 'cancel') {
// Cancel the request
const updated = await prisma.request.update({
where: { id },
data: {
status: 'cancelled',
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
return NextResponse.json({
success: true,
request: updated,
message: 'Request cancelled successfully',
});
} else if (action === 'retry') {
// Retry failed request - allow users to retry their own warn/failed requests
// Only allow retry for failed, warn, or awaiting_* statuses
const retryableStatuses = ['failed', 'warn', 'awaiting_search', 'awaiting_import'];
if (!retryableStatuses.includes(requestRecord.status)) {
return NextResponse.json(
{
error: 'ValidationError',
message: `Cannot retry request with status: ${requestRecord.status}`,
},
{ status: 400 }
);
}
// Determine which job to trigger based on the current status
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
let jobType: string;
let updated;
if (requestRecord.status === 'warn' || requestRecord.status === 'awaiting_import') {
// Retry import
const requestWithData = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
downloadHistory: {
where: { selected: true },
orderBy: { createdAt: 'desc' },
take: 1,
},
},
});
if (!requestWithData || !requestWithData.downloadHistory[0]) {
return NextResponse.json(
{
error: 'ValidationError',
message: 'No download history found, cannot retry import',
},
{ status: 400 }
);
}
const downloadHistory = requestWithData.downloadHistory[0];
// Get download path from qBittorrent
const { getQBittorrentService } = await import('@/lib/integrations/qbittorrent.service');
const qbt = await getQBittorrentService();
const torrent = await qbt.getTorrent(downloadHistory.downloadClientId!);
const downloadPath = `${torrent.save_path}/${torrent.name}`;
await jobQueue.addOrganizeJob(
id,
requestWithData.audiobook.id,
downloadPath,
`/media/audiobooks/${requestWithData.audiobook.author}/${requestWithData.audiobook.title}`
);
updated = await prisma.request.update({
where: { id },
data: {
status: 'processing',
progress: 100,
errorMessage: null,
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
jobType = 'import';
} else {
// Retry search
const requestWithData = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
},
});
if (!requestWithData) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
await jobQueue.addSearchJob(id, {
id: requestWithData.audiobook.id,
title: requestWithData.audiobook.title,
author: requestWithData.audiobook.author,
});
updated = await prisma.request.update({
where: { id },
data: {
status: 'pending',
progress: 0,
errorMessage: null,
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
jobType = 'search';
}
return NextResponse.json({
success: true,
request: updated,
message: `Request retry initiated (${jobType})`,
});
}
return NextResponse.json(
{
error: 'ValidationError',
message: 'Invalid action',
},
{ status: 400 }
);
} catch (error) {
console.error('Failed to update request:', error);
return NextResponse.json(
{
error: 'UpdateError',
message: 'Failed to update request',
},
{ status: 500 }
);
}
});
}
/**
* DELETE /api/requests/[id]
* Delete a request (admin only)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
if (req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'Admin access required' },
{ status: 403 }
);
}
const { id } = await params;
await prisma.request.delete({
where: { id },
});
return NextResponse.json({
success: true,
message: 'Request deleted successfully',
});
} catch (error) {
console.error('Failed to delete request:', error);
return NextResponse.json(
{
error: 'DeleteError',
message: 'Failed to delete request',
},
{ status: 500 }
);
}
});
}
@@ -0,0 +1,106 @@
/**
* Component: Select Torrent API
* Documentation: documentation/phase3/prowlarr.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { TorrentResult } from '@/lib/utils/ranking-algorithm';
/**
* POST /api/requests/[id]/select-torrent
* Select and download a specific torrent from interactive search results
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const { id } = await params;
const body = await req.json();
const { torrent } = body as { torrent: TorrentResult };
if (!torrent) {
return NextResponse.json(
{ error: 'ValidationError', message: 'Torrent data is required' },
{ status: 400 }
);
}
const requestRecord = await prisma.request.findUnique({
where: { id },
include: {
audiobook: true,
},
});
if (!requestRecord) {
return NextResponse.json(
{ error: 'NotFound', message: 'Request not found' },
{ status: 404 }
);
}
// Check authorization
if (requestRecord.userId !== req.user.id && req.user.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden', message: 'You do not have access to this request' },
{ status: 403 }
);
}
console.log(`[SelectTorrent] User selected torrent: ${torrent.title} for request ${id}`);
// Trigger download job with the selected torrent
const jobQueue = getJobQueueService();
await jobQueue.addDownloadJob(
id,
{
id: requestRecord.audiobook.id,
title: requestRecord.audiobook.title,
author: requestRecord.audiobook.author,
},
torrent
);
// Update request status
const updated = await prisma.request.update({
where: { id },
data: {
status: 'downloading',
progress: 0,
errorMessage: null,
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
return NextResponse.json({
success: true,
request: updated,
message: 'Torrent download initiated',
});
} catch (error) {
console.error('Failed to select torrent:', error);
return NextResponse.json(
{
error: 'DownloadError',
message: error instanceof Error ? error.message : 'Failed to initiate torrent download',
},
{ status: 500 }
);
}
});
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Component: Requests API Routes
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getJobQueueService } from '@/lib/services/job-queue.service';
import { findPlexMatch } from '@/lib/utils/audiobook-matcher';
import { z } from 'zod';
const CreateRequestSchema = z.object({
audiobook: z.object({
asin: z.string(),
title: z.string(),
author: z.string(),
narrator: z.string().optional(),
description: z.string().optional(),
coverArtUrl: z.string().optional(),
durationMinutes: z.number().optional(),
releaseDate: z.string().optional(),
rating: z.number().optional(),
}),
});
/**
* POST /api/requests
* Create a new audiobook request
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const body = await req.json();
const { audiobook } = CreateRequestSchema.parse(body);
// Check if audiobook is already available in Plex library
const plexMatch = await findPlexMatch({
asin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
});
if (plexMatch) {
return NextResponse.json(
{
error: 'AlreadyAvailable',
message: 'This audiobook is already available in your Plex library',
plexGuid: plexMatch.plexGuid,
},
{ status: 409 }
);
}
// Try to find existing audiobook record by ASIN
let audiobookRecord = await prisma.audiobook.findFirst({
where: { audibleAsin: audiobook.asin },
});
// If not found, create new audiobook record
if (!audiobookRecord) {
audiobookRecord = await prisma.audiobook.create({
data: {
audibleAsin: audiobook.asin,
title: audiobook.title,
author: audiobook.author,
narrator: audiobook.narrator,
description: audiobook.description,
coverArtUrl: audiobook.coverArtUrl,
status: 'requested',
},
});
}
// Check if user already has a request for this audiobook
const existingRequest = await prisma.request.findUnique({
where: {
userId_audiobookId: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
},
},
});
if (existingRequest) {
// Allow re-requesting if the status is failed, warn, or cancelled
const canReRequest = ['failed', 'warn', 'cancelled'].includes(existingRequest.status);
if (!canReRequest) {
return NextResponse.json(
{
error: 'DuplicateRequest',
message: 'You have already requested this audiobook',
request: existingRequest,
},
{ status: 409 }
);
}
// Delete the existing failed/warn/cancelled request
console.log(`[Requests] Deleting existing ${existingRequest.status} request ${existingRequest.id} to allow re-request`);
await prisma.request.delete({
where: { id: existingRequest.id },
});
}
// Create request
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: 'pending',
progress: 0,
},
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
});
// Trigger search job
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
});
return NextResponse.json({
success: true,
request: newRequest,
}, { status: 201 });
} catch (error) {
console.error('Failed to create request:', error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'ValidationError',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'RequestError',
message: 'Failed to create audiobook request',
},
{ status: 500 }
);
}
});
}
/**
* GET /api/requests?status=pending&limit=50
* Get user's audiobook requests (or all requests for admins)
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
if (!req.user) {
return NextResponse.json(
{ error: 'Unauthorized', message: 'User not authenticated' },
{ status: 401 }
);
}
const searchParams = req.nextUrl.searchParams;
const status = searchParams.get('status');
const limit = parseInt(searchParams.get('limit') || '50', 10);
const myOnly = searchParams.get('myOnly') === 'true';
const isAdmin = req.user.role === 'admin';
// Build query
// If myOnly=true, always filter by current user (even for admins)
// Otherwise, admins see all requests, users see only their own
const where: any = myOnly || !isAdmin ? { userId: req.user.id } : {};
if (status) {
where.status = status;
}
const requests = await prisma.request.findMany({
where,
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: limit,
});
return NextResponse.json({
success: true,
requests,
count: requests.length,
});
} catch (error) {
console.error('Failed to get requests:', error);
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch requests',
},
{ status: 500 }
);
}
});
}
+435
View File
@@ -0,0 +1,435 @@
/**
* Component: Setup Wizard Complete API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import bcrypt from 'bcrypt';
import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt';
import { getEncryptionService } from '@/lib/services/encryption.service';
import { getPlexService } from '@/lib/integrations/plex.service';
export async function POST(request: NextRequest) {
try {
const {
backendMode,
admin,
plex,
audiobookshelf,
authMethod,
oidc,
registration,
prowlarr,
downloadClient,
paths,
bookdate,
} = await request.json();
// Validate backend mode
if (!backendMode || !['plex', 'audiobookshelf'].includes(backendMode)) {
return NextResponse.json(
{ success: false, error: 'Invalid or missing backend mode' },
{ status: 400 }
);
}
// Validate required fields based on backend mode
if (backendMode === 'plex') {
if (
!admin?.username ||
!admin?.password ||
!plex?.url ||
!plex?.token ||
!plex?.audiobook_library_id
) {
return NextResponse.json(
{ success: false, error: 'Missing required Plex configuration fields' },
{ status: 400 }
);
}
} else {
// Audiobookshelf mode
if (
!audiobookshelf?.server_url ||
!audiobookshelf?.api_token ||
!audiobookshelf?.library_id
) {
return NextResponse.json(
{ success: false, error: 'Missing required Audiobookshelf configuration fields' },
{ status: 400 }
);
}
if (!authMethod || !['oidc', 'manual', 'both'].includes(authMethod)) {
return NextResponse.json(
{ success: false, error: 'Invalid or missing authentication method' },
{ status: 400 }
);
}
}
// Validate common required fields
if (
!prowlarr?.url ||
!prowlarr?.api_key ||
!prowlarr?.indexers ||
!Array.isArray(prowlarr.indexers) ||
prowlarr.indexers.length === 0 ||
!downloadClient?.type ||
!downloadClient?.url ||
!downloadClient?.username ||
!downloadClient?.password ||
!paths?.download_dir ||
!paths?.media_dir
) {
return NextResponse.json(
{ success: false, error: 'Missing required configuration fields' },
{ status: 400 }
);
}
// Create admin user (for Plex mode or ABS + Manual auth)
let adminUser: any = null;
let accessToken: string | null = null;
let refreshToken: string | null = null;
if (backendMode === 'plex' || (backendMode === 'audiobookshelf' && admin)) {
if (!admin?.username || !admin?.password) {
return NextResponse.json(
{ success: false, error: 'Admin credentials required' },
{ status: 400 }
);
}
const hashedPassword = await bcrypt.hash(admin.password, 10);
const encryptionService = getEncryptionService();
const encryptedPassword = encryptionService.encrypt(hashedPassword);
adminUser = await prisma.user.create({
data: {
plexId: `local-${admin.username}`,
plexUsername: admin.username,
plexEmail: null,
role: 'admin',
isSetupAdmin: true, // Mark as setup admin - role cannot be changed
avatarUrl: null,
authToken: encryptedPassword, // Store encrypted hashed password
authProvider: backendMode === 'plex' ? 'plex' : 'local',
registrationStatus: 'approved',
lastLoginAt: new Date(),
},
});
// Generate JWT tokens for auto-login
accessToken = generateAccessToken({
sub: adminUser.id,
plexId: adminUser.plexId,
username: adminUser.plexUsername,
role: adminUser.role,
});
refreshToken = generateRefreshToken(adminUser.id);
}
// Save configuration to database
// Use upsert to handle both initial setup and updates
const encryptionService = getEncryptionService();
// Save backend mode
await prisma.configuration.upsert({
where: { key: 'system.backend_mode' },
update: { value: backendMode },
create: { key: 'system.backend_mode', value: backendMode },
});
if (backendMode === 'plex') {
// Plex configuration
await prisma.configuration.upsert({
where: { key: 'plex_url' },
update: { value: plex.url },
create: { key: 'plex_url', value: plex.url },
});
await prisma.configuration.upsert({
where: { key: 'plex_token' },
update: { value: plex.token },
create: { key: 'plex_token', value: plex.token },
});
await prisma.configuration.upsert({
where: { key: 'plex_audiobook_library_id' },
update: { value: plex.audiobook_library_id },
create: { key: 'plex_audiobook_library_id', value: plex.audiobook_library_id },
});
// Get and save machine identifier (for server-specific access tokens)
// Fetch from Plex if not provided by frontend
let machineIdentifier = plex.machine_identifier;
if (!machineIdentifier) {
try {
const plexService = getPlexService();
const serverInfo = await plexService.testConnection(plex.url, plex.token);
if (serverInfo.success && serverInfo.info?.machineIdentifier) {
machineIdentifier = serverInfo.info.machineIdentifier;
console.log('[Setup] Fetched machineIdentifier:', machineIdentifier);
} else {
console.warn('[Setup] Could not fetch machineIdentifier');
}
} catch (error) {
console.error('[Setup] Error fetching machineIdentifier:', error);
}
}
if (machineIdentifier) {
await prisma.configuration.upsert({
where: { key: 'plex_machine_identifier' },
update: { value: machineIdentifier },
create: { key: 'plex_machine_identifier', value: machineIdentifier },
});
}
} else {
// Audiobookshelf configuration
await prisma.configuration.upsert({
where: { key: 'audiobookshelf.server_url' },
update: { value: audiobookshelf.server_url },
create: { key: 'audiobookshelf.server_url', value: audiobookshelf.server_url },
});
const encryptedAbsToken = encryptionService.encrypt(audiobookshelf.api_token);
await prisma.configuration.upsert({
where: { key: 'audiobookshelf.api_token' },
update: { value: encryptedAbsToken, encrypted: true },
create: { key: 'audiobookshelf.api_token', value: encryptedAbsToken, encrypted: true },
});
await prisma.configuration.upsert({
where: { key: 'audiobookshelf.library_id' },
update: { value: audiobookshelf.library_id },
create: { key: 'audiobookshelf.library_id', value: audiobookshelf.library_id },
});
// OIDC configuration (if enabled)
if (authMethod === 'oidc' || authMethod === 'both') {
await prisma.configuration.upsert({
where: { key: 'oidc.enabled' },
update: { value: 'true' },
create: { key: 'oidc.enabled', value: 'true' },
});
await prisma.configuration.upsert({
where: { key: 'oidc.provider_name' },
update: { value: oidc.provider_name },
create: { key: 'oidc.provider_name', value: oidc.provider_name },
});
await prisma.configuration.upsert({
where: { key: 'oidc.issuer_url' },
update: { value: oidc.issuer_url },
create: { key: 'oidc.issuer_url', value: oidc.issuer_url },
});
await prisma.configuration.upsert({
where: { key: 'oidc.client_id' },
update: { value: oidc.client_id },
create: { key: 'oidc.client_id', value: oidc.client_id },
});
const encryptedClientSecret = encryptionService.encrypt(oidc.client_secret);
await prisma.configuration.upsert({
where: { key: 'oidc.client_secret' },
update: { value: encryptedClientSecret, encrypted: true },
create: { key: 'oidc.client_secret', value: encryptedClientSecret, encrypted: true },
});
}
// Manual registration configuration (if enabled)
if (authMethod === 'manual' || authMethod === 'both') {
await prisma.configuration.upsert({
where: { key: 'auth.registration_enabled' },
update: { value: 'true' },
create: { key: 'auth.registration_enabled', value: 'true' },
});
await prisma.configuration.upsert({
where: { key: 'auth.require_admin_approval' },
update: { value: registration.require_admin_approval ? 'true' : 'false' },
create: {
key: 'auth.require_admin_approval',
value: registration.require_admin_approval ? 'true' : 'false',
},
});
}
}
// Prowlarr configuration
await prisma.configuration.upsert({
where: { key: 'prowlarr_url' },
update: { value: prowlarr.url },
create: { key: 'prowlarr_url', value: prowlarr.url },
});
await prisma.configuration.upsert({
where: { key: 'prowlarr_api_key' },
update: { value: prowlarr.api_key },
create: { key: 'prowlarr_api_key', value: prowlarr.api_key },
});
await prisma.configuration.upsert({
where: { key: 'prowlarr_indexers' },
update: { value: JSON.stringify(prowlarr.indexers) },
create: { key: 'prowlarr_indexers', value: JSON.stringify(prowlarr.indexers) },
});
// Download client configuration
await prisma.configuration.upsert({
where: { key: 'download_client_type' },
update: { value: downloadClient.type },
create: { key: 'download_client_type', value: downloadClient.type },
});
await prisma.configuration.upsert({
where: { key: 'download_client_url' },
update: { value: downloadClient.url },
create: { key: 'download_client_url', value: downloadClient.url },
});
await prisma.configuration.upsert({
where: { key: 'download_client_username' },
update: { value: downloadClient.username },
create: { key: 'download_client_username', value: downloadClient.username },
});
await prisma.configuration.upsert({
where: { key: 'download_client_password' },
update: { value: downloadClient.password },
create: { key: 'download_client_password', value: downloadClient.password },
});
// Path configuration
await prisma.configuration.upsert({
where: { key: 'download_dir' },
update: { value: paths.download_dir },
create: { key: 'download_dir', value: paths.download_dir },
});
await prisma.configuration.upsert({
where: { key: 'media_dir' },
update: { value: paths.media_dir },
create: { key: 'media_dir', value: paths.media_dir },
});
// Metadata tagging configuration
await prisma.configuration.upsert({
where: { key: 'metadata_tagging_enabled' },
update: { value: String(paths.metadata_tagging_enabled ?? true) },
create: {
key: 'metadata_tagging_enabled',
value: String(paths.metadata_tagging_enabled ?? true),
category: 'automation',
description: 'Automatically tag audio files with correct metadata during file organization'
},
});
// BookDate configuration (optional, global for all users)
// Note: libraryScope and customPrompt are now per-user settings, not required here
if (bookdate && bookdate.provider && bookdate.apiKey && bookdate.model) {
console.log('[Setup] Saving global BookDate configuration');
const encryptionService = getEncryptionService();
const encryptedApiKey = encryptionService.encrypt(bookdate.apiKey);
// Check if global config already exists
const existingConfig = await prisma.bookDateConfig.findFirst();
if (existingConfig) {
// Update existing global config
await prisma.bookDateConfig.update({
where: { id: existingConfig.id },
data: {
provider: bookdate.provider,
apiKey: encryptedApiKey,
model: bookdate.model,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isVerified: true,
isEnabled: true,
},
});
} else {
// Create new global config
await prisma.bookDateConfig.create({
data: {
provider: bookdate.provider,
apiKey: encryptedApiKey,
model: bookdate.model,
libraryScope: 'full', // Default value for backwards compatibility
customPrompt: null,
isVerified: true,
isEnabled: true,
},
});
}
console.log('[Setup] Global BookDate configuration saved');
} else {
console.log('[Setup] BookDate configuration skipped (missing provider, apiKey, or model)');
}
// Mark setup as complete
await prisma.configuration.upsert({
where: { key: 'setup_completed' },
update: { value: 'true' },
create: { key: 'setup_completed', value: 'true' },
});
// Enable auto jobs (Plex Library Scan and Audible Data Refresh)
await prisma.scheduledJob.updateMany({
where: {
type: {
in: ['plex_library_scan', 'audible_refresh'],
},
},
data: {
enabled: true,
},
});
console.log('[Setup] Auto jobs enabled');
console.log('[Setup] Configuration saved successfully');
// Return response with tokens if admin user was created
if (adminUser && accessToken && refreshToken) {
return NextResponse.json({
success: true,
message: 'Setup completed successfully',
accessToken,
refreshToken,
user: {
id: adminUser.id,
plexId: adminUser.plexId,
username: adminUser.plexUsername,
email: adminUser.plexEmail,
role: adminUser.role,
avatarUrl: adminUser.avatarUrl,
},
});
} else {
// OIDC-only mode - no admin user created yet
return NextResponse.json({
success: true,
message: 'Setup completed successfully. First OIDC login will become admin.',
});
}
} catch (error) {
console.error('[Setup] Failed to save configuration:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to save configuration',
},
{ status: 500 }
);
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Component: Setup Status Check API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
/**
* GET /api/setup/status
* Returns whether initial setup has been completed
* Used by middleware for routing logic
*/
export async function GET(request: NextRequest) {
try {
const config = await prisma.configuration.findUnique({
where: { key: 'setup_completed' },
});
const setupComplete = config?.value === 'true';
return NextResponse.json({
setupComplete,
});
} catch (error) {
// If database is not ready or table doesn't exist, setup is not complete
console.error('[Setup Status] Check failed:', error);
return NextResponse.json({
setupComplete: false,
});
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Component: Test Audiobookshelf Connection
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const { serverUrl, apiToken } = await request.json();
if (!serverUrl) {
return NextResponse.json(
{ error: 'Server URL is required' },
{ status: 400 }
);
}
// If API token is masked, try to get the saved token
let effectiveApiToken = apiToken;
if (!apiToken || apiToken.startsWith('••••')) {
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const savedToken = await configService.get('audiobookshelf.api_token');
if (!savedToken) {
return NextResponse.json(
{ error: 'API token is required' },
{ status: 400 }
);
}
effectiveApiToken = savedToken;
}
// Test connection by fetching libraries (which also validates auth)
const libResponse = await fetch(`${serverUrl.replace(/\/$/, '')}/api/libraries`, {
headers: {
'Authorization': `Bearer ${effectiveApiToken}`,
},
});
if (!libResponse.ok) {
return NextResponse.json(
{ error: `Connection failed: ${libResponse.status} ${libResponse.statusText}` },
{ status: 400 }
);
}
const libData = await libResponse.json();
// Check if response has libraries array
if (!libData.libraries || !Array.isArray(libData.libraries)) {
return NextResponse.json(
{ error: 'Invalid response from Audiobookshelf server' },
{ status: 400 }
);
}
const libraries = libData.libraries
.filter((lib: any) => lib.mediaType === 'book')
.map((lib: any) => ({
id: lib.id,
name: lib.name,
itemCount: lib.stats?.totalItems || 0,
}));
return NextResponse.json({
success: true,
serverInfo: {
name: 'Audiobookshelf',
version: 'Connected',
},
libraries,
});
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Connection failed' },
{ status: 500 }
);
}
}
@@ -0,0 +1,48 @@
/**
* Component: Setup Wizard Test Download Client API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
export async function POST(request: NextRequest) {
try {
const { type, url, username, password } = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
{ success: false, error: 'All fields are required' },
{ status: 400 }
);
}
if (type !== 'qbittorrent') {
return NextResponse.json(
{ success: false, error: 'Only qBittorrent is currently supported' },
{ status: 400 }
);
}
// Test connection with custom credentials
const version = await QBittorrentService.testConnectionWithCredentials(
url,
username,
password
);
return NextResponse.json({
success: true,
version,
});
} catch (error) {
console.error('[Setup] Download client test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to download client',
},
{ status: 500 }
);
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Test OIDC Configuration Endpoint
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { Issuer } from 'openid-client';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { issuerUrl, clientId, clientSecret } = body;
// Validate required fields
if (!issuerUrl || !clientId || !clientSecret) {
return NextResponse.json(
{
success: false,
error: 'Issuer URL, Client ID, and Client Secret are required'
},
{ status: 400 }
);
}
// Validate issuer URL format
try {
new URL(issuerUrl);
} catch {
return NextResponse.json(
{
success: false,
error: 'Invalid issuer URL format'
},
{ status: 400 }
);
}
// Attempt OIDC discovery
const issuer = await Issuer.discover(issuerUrl);
// Validate that we got the necessary endpoints
if (!issuer.metadata.authorization_endpoint ||
!issuer.metadata.token_endpoint ||
!issuer.metadata.userinfo_endpoint) {
return NextResponse.json(
{
success: false,
error: 'OIDC provider is missing required endpoints'
},
{ status: 500 }
);
}
// Return success with discovered metadata
return NextResponse.json({
success: true,
issuer: {
issuer: issuer.issuer,
authorizationEndpoint: issuer.metadata.authorization_endpoint,
tokenEndpoint: issuer.metadata.token_endpoint,
userinfoEndpoint: issuer.metadata.userinfo_endpoint,
jwksUri: issuer.metadata.jwks_uri,
supportedScopes: issuer.metadata.scopes_supported || [],
supportedResponseTypes: issuer.metadata.response_types_supported || [],
},
});
} catch (error) {
console.error('[Test OIDC] Discovery failed:', error);
// Determine error message
let errorMessage = 'OIDC discovery failed';
if (error instanceof Error) {
errorMessage = error.message;
// Provide more helpful messages for common errors
if (errorMessage.includes('ENOTFOUND') || errorMessage.includes('ECONNREFUSED')) {
errorMessage = 'Cannot reach OIDC provider. Check the issuer URL and network connectivity.';
} else if (errorMessage.includes('404')) {
errorMessage = 'OIDC discovery endpoint not found. Verify the issuer URL is correct.';
} else if (errorMessage.includes('timeout')) {
errorMessage = 'Connection to OIDC provider timed out. Check the issuer URL.';
}
}
return NextResponse.json(
{
success: false,
error: errorMessage
},
{ status: 500 }
);
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Component: Setup Wizard Test Paths API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
async function testPath(dirPath: string): Promise<boolean> {
try {
// Try to access the path
try {
await fs.access(dirPath);
console.log(`[Setup] Path exists: ${dirPath}`);
} catch (accessError) {
// Path doesn't exist, try to create it
console.log(`[Setup] Path doesn't exist, creating: ${dirPath}`);
try {
await fs.mkdir(dirPath, { recursive: true });
console.log(`[Setup] Successfully created path: ${dirPath}`);
} catch (mkdirError) {
console.error(`[Setup] Failed to create path ${dirPath}:`, mkdirError);
// If mkdir fails, it means the parent mount doesn't exist or isn't writable
return false;
}
}
// Test write permissions by creating a test file
const testFile = path.join(dirPath, '.readmeabook-test');
await fs.writeFile(testFile, 'test');
// Clean up test file
await fs.unlink(testFile);
return true;
} catch (error) {
console.error(`[Setup] Path test failed for ${dirPath}:`, error);
return false;
}
}
export async function POST(request: NextRequest) {
try {
const { downloadDir, mediaDir } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
{ success: false, error: 'Both directory paths are required' },
{ status: 400 }
);
}
// Test both paths
const downloadDirValid = await testPath(downloadDir);
const mediaDirValid = await testPath(mediaDir);
const success = downloadDirValid && mediaDirValid;
if (!success) {
const errors = [];
if (!downloadDirValid) {
errors.push('Download directory path is invalid or parent mount is not writable');
}
if (!mediaDirValid) {
errors.push('Media directory path is invalid or parent mount is not writable');
}
return NextResponse.json({
success: false,
downloadDirValid,
mediaDirValid,
error: errors.join('. '),
});
}
return NextResponse.json({
success: true,
downloadDirValid,
mediaDirValid,
message: 'Directories are ready and writable (created if needed)',
});
} catch (error) {
console.error('[Setup] Path validation failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Path validation failed',
},
{ status: 500 }
);
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Component: Setup Wizard Test Plex API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { getPlexService } from '@/lib/integrations/plex.service';
export async function POST(request: NextRequest) {
try {
const { url, token } = await request.json();
if (!url || !token) {
return NextResponse.json(
{ success: false, error: 'URL and token are required' },
{ status: 400 }
);
}
const plexService = getPlexService();
// Test connection and get server info
const connectionResult = await plexService.testConnection(url, token);
if (!connectionResult.success || !connectionResult.info) {
return NextResponse.json(
{ success: false, error: connectionResult.message },
{ status: 400 }
);
}
// Get libraries
const libraries = await plexService.getLibraries(url, token);
// Format server name safely
const serverName = connectionResult.info
? `${connectionResult.info.platform || 'Plex Server'} v${connectionResult.info.version || 'Unknown'}`
: 'Plex Server';
return NextResponse.json({
success: true,
serverName,
version: connectionResult.info?.version || 'Unknown',
machineIdentifier: connectionResult.info?.machineIdentifier || 'unknown',
libraries: libraries.map((lib) => ({
id: lib.id,
title: lib.title,
type: lib.type,
})),
});
} catch (error) {
console.error('[Setup] Plex test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Plex',
},
{ status: 500 }
);
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Component: Setup Wizard Test Prowlarr API
* Documentation: documentation/setup-wizard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
export async function POST(request: NextRequest) {
try {
const { url, apiKey } = await request.json();
if (!url || !apiKey) {
return NextResponse.json(
{ success: false, error: 'URL and API key are required' },
{ status: 400 }
);
}
// Create a new ProwlarrService instance with test credentials
const prowlarrService = new ProwlarrService(url, apiKey);
// Test connection and get indexers
const indexers = await prowlarrService.getIndexers();
// Only return enabled indexers
const enabledIndexers = indexers.filter((indexer) => indexer.enable);
return NextResponse.json({
success: true,
indexerCount: enabledIndexers.length,
totalIndexers: indexers.length,
indexers: enabledIndexers.map((indexer) => ({
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
})),
});
} catch (error) {
console.error('[Setup] Prowlarr test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Prowlarr',
},
{ status: 500 }
);
}
}
+337
View File
@@ -0,0 +1,337 @@
/**
* Component: Plex Home Profile Selection Page
* Documentation: documentation/backend/services/auth.md
*/
'use client';
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
interface PlexHomeUser {
id: string;
uuid: string;
title: string;
friendlyName: string;
username: string;
email: string;
thumb: string;
hasPassword: boolean;
restricted: boolean;
admin: boolean;
guest: boolean;
protected: boolean;
}
function SelectProfileContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { setAuthData } = useAuth();
const [profiles, setProfiles] = useState<PlexHomeUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [pin, setPin] = useState('');
const [showPinInput, setShowPinInput] = useState(false);
const [pinError, setPinError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Get token from session storage (set by OAuth callback)
const mainAccountToken = typeof window !== 'undefined' ? sessionStorage.getItem('plex_main_token') : null;
const pinId = searchParams.get('pinId');
useEffect(() => {
if (!mainAccountToken || !pinId) {
setError('Invalid session. Please try logging in again.');
setIsLoading(false);
return;
}
// Fetch home users
const fetchProfiles = async () => {
try {
const response = await fetch('/api/auth/plex/home-users', {
headers: {
'X-Plex-Token': mainAccountToken,
},
});
if (!response.ok) {
throw new Error('Failed to fetch profiles');
}
const data = await response.json();
setProfiles(data.users || []);
setIsLoading(false);
} catch (err) {
console.error('Failed to fetch profiles:', err);
setError('Failed to load profiles. Please try again.');
setIsLoading(false);
}
};
fetchProfiles();
}, [mainAccountToken, pinId]);
const handleProfileSelect = async (profile: PlexHomeUser) => {
setSelectedProfile(profile.id);
setPinError(null);
// If profile is protected, show PIN input
if (profile.protected && profile.hasPassword) {
setShowPinInput(true);
return;
}
// Otherwise, proceed with selection
await completeProfileSelection(profile.id, undefined);
};
const handlePinSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedProfile || !pin) return;
await completeProfileSelection(selectedProfile, pin);
};
const completeProfileSelection = async (profileId: string, profilePin?: string) => {
if (!mainAccountToken) return;
setIsSubmitting(true);
setPinError(null);
// Get the selected profile info
const selectedProfileInfo = profiles.find(p => p.id === profileId);
if (!selectedProfileInfo) {
setError('Selected profile not found');
setIsSubmitting(false);
return;
}
try {
const response = await fetch('/api/auth/plex/switch-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Plex-Token': mainAccountToken,
},
body: JSON.stringify({
userId: profileId,
pin: profilePin,
pinId,
profileInfo: {
friendlyName: selectedProfileInfo.friendlyName,
email: selectedProfileInfo.email,
thumb: selectedProfileInfo.thumb,
uuid: selectedProfileInfo.uuid,
},
}),
});
const data = await response.json();
if (!response.ok) {
if (response.status === 401) {
setPinError('Invalid PIN. Please try again.');
setPin('');
setIsSubmitting(false);
return;
}
throw new Error(data.message || 'Failed to switch profile');
}
// Success! Store auth data and redirect
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
localStorage.setItem('user', JSON.stringify(data.user));
// Update auth context
setAuthData(data.user, data.accessToken);
// Clear session storage
sessionStorage.removeItem('plex_main_token');
// Redirect to home
router.push('/');
} catch (err: any) {
console.error('Failed to select profile:', err);
setError(err.message || 'Failed to select profile. Please try again.');
setIsSubmitting(false);
setShowPinInput(false);
setSelectedProfile(null);
setPin('');
}
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
<p className="text-gray-300">Loading profiles...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="bg-gray-800 p-8 rounded-lg shadow-xl max-w-md w-full">
<h1 className="text-2xl font-bold text-red-500 mb-4">Error</h1>
<p className="text-gray-300 mb-6">{error}</p>
<button
onClick={() => router.push('/login')}
className="w-full bg-orange-600 hover:bg-orange-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors"
>
Back to Login
</button>
</div>
</div>
);
}
if (showPinInput && selectedProfile) {
const profile = profiles.find(p => p.id === selectedProfile);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 p-4">
<div className="bg-gray-800 p-8 rounded-lg shadow-xl max-w-md w-full">
<button
onClick={() => {
setShowPinInput(false);
setSelectedProfile(null);
setPin('');
setPinError(null);
}}
className="text-gray-400 hover:text-gray-300 mb-4"
>
Back to profiles
</button>
<div className="text-center mb-6">
{profile?.thumb && (
<div className="w-24 h-24 mx-auto mb-4 rounded-full overflow-hidden bg-gray-700">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={profile.thumb}
alt={profile.friendlyName}
className="w-full h-full object-cover"
/>
</div>
)}
<h2 className="text-2xl font-bold text-white mb-2">{profile?.friendlyName}</h2>
<p className="text-gray-400">Enter PIN to continue</p>
</div>
<form onSubmit={handlePinSubmit}>
<input
type="password"
inputMode="numeric"
pattern="[0-9]*"
placeholder="Enter PIN"
value={pin}
onChange={(e) => setPin(e.target.value)}
className="w-full bg-gray-700 text-white border border-gray-600 rounded-lg px-4 py-3 mb-4 focus:outline-none focus:ring-2 focus:ring-orange-500 text-center text-2xl tracking-widest"
maxLength={4}
autoFocus
disabled={isSubmitting}
/>
{pinError && (
<p className="text-red-500 text-sm mb-4">{pinError}</p>
)}
<button
type="submit"
disabled={!pin || isSubmitting}
className="w-full bg-orange-600 hover:bg-orange-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-semibold py-3 px-6 rounded-lg transition-colors"
>
{isSubmitting ? 'Continuing...' : 'Continue'}
</button>
</form>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900 p-4">
<div className="bg-gray-800 p-8 rounded-lg shadow-xl max-w-4xl w-full">
<h1 className="text-3xl font-bold text-white mb-2 text-center">Who's listening?</h1>
<p className="text-gray-400 mb-8 text-center">Select your profile to continue</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
{profiles.map((profile) => (
<button
key={profile.id}
onClick={() => handleProfileSelect(profile)}
disabled={isSubmitting}
className="group flex flex-col items-center p-4 rounded-lg hover:bg-gray-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="relative w-24 h-24 mb-3 rounded-full overflow-hidden bg-gray-700 ring-4 ring-transparent group-hover:ring-orange-500 transition-all">
{profile.thumb ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={profile.thumb}
alt={profile.friendlyName}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-orange-500 to-red-600">
<span className="text-3xl font-bold text-white">
{profile.friendlyName.charAt(0).toUpperCase()}
</span>
</div>
)}
{profile.protected && (
<div className="absolute bottom-0 right-0 bg-gray-900 rounded-full p-1">
<svg className="w-4 h-4 text-orange-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
<span className="text-white font-medium text-center group-hover:text-orange-500 transition-colors">
{profile.friendlyName}
</span>
{profile.restricted && (
<span className="text-xs text-gray-500 mt-1">Managed</span>
)}
</button>
))}
</div>
{profiles.length === 0 && (
<div className="text-center py-12">
<p className="text-gray-400 mb-4">No profiles found for this account.</p>
<button
onClick={() => router.push('/login')}
className="text-orange-500 hover:text-orange-400"
>
Back to Login
</button>
</div>
)}
</div>
</div>
);
}
export default function SelectProfilePage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
<p className="text-gray-300">Loading...</p>
</div>
</div>
}
>
<SelectProfileContent />
</Suspense>
);
}
+381
View File
@@ -0,0 +1,381 @@
/**
* Component: BookDate Main Page
* Documentation: documentation/features/bookdate-prd.md
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { RecommendationCard } from '@/components/bookdate/RecommendationCard';
import { LoadingScreen } from '@/components/bookdate/LoadingScreen';
import { SettingsWidget } from '@/components/bookdate/SettingsWidget';
export default function BookDatePage() {
const [recommendations, setRecommendations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const [lastSwipe, setLastSwipe] = useState<any>(null);
const [showUndo, setShowUndo] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [isOnboarding, setIsOnboarding] = useState(false);
const [checkingOnboarding, setCheckingOnboarding] = useState(true);
const router = useRouter();
useEffect(() => {
checkOnboardingStatus();
}, []);
const checkOnboardingStatus = async () => {
setCheckingOnboarding(true);
try {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
router.push('/login');
return;
}
const response = await fetch('/api/bookdate/preferences', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const data = await response.json();
if (!response.ok) {
console.error('Failed to check onboarding status:', data.error);
// Continue to recommendations anyway on error
loadRecommendations();
return;
}
// Check if user has completed onboarding
if (!data.onboardingComplete) {
// First time user - show onboarding settings
setIsOnboarding(true);
setShowSettings(true);
setLoading(false);
} else {
// Existing user - load recommendations normally
loadRecommendations();
}
} catch (error: any) {
console.error('Check onboarding error:', error);
// Continue to recommendations anyway on error
loadRecommendations();
} finally {
setCheckingOnboarding(false);
}
};
const handleOnboardingComplete = () => {
// Onboarding is done, now load recommendations
setIsOnboarding(false);
setShowSettings(false);
loadRecommendations();
};
const loadRecommendations = async () => {
setLoading(true);
setError(null);
try {
const accessToken = localStorage.getItem('accessToken');
if (!accessToken) {
router.push('/login');
return;
}
const response = await fetch('/api/bookdate/recommendations', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to load recommendations');
return;
}
setRecommendations(data.recommendations || []);
setCurrentIndex(0);
} catch (error: any) {
console.error('Load recommendations error:', error);
setError(error.message || 'Failed to load recommendations');
} finally {
setLoading(false);
}
};
const handleSwipe = async (
action: 'left' | 'right' | 'up',
markedAsKnown = false
) => {
const recommendation = recommendations[currentIndex];
try {
const accessToken = localStorage.getItem('accessToken');
await fetch('/api/bookdate/swipe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({
recommendationId: recommendation.id,
action,
markedAsKnown,
}),
});
// Store last swipe for undo functionality
if (action !== 'right') {
setLastSwipe({ recommendation, action, index: currentIndex });
setShowUndo(true);
// Hide undo button after 3 seconds
setTimeout(() => {
setShowUndo(false);
}, 3000);
}
// Move to next recommendation
setCurrentIndex(currentIndex + 1);
// Check if we need to load more recommendations
if (currentIndex + 1 >= recommendations.length) {
// At the end - could auto-load more or show empty state
}
} catch (error) {
console.error('Swipe error:', error);
// Don't block user, just log error
}
};
const handleUndo = async () => {
if (!lastSwipe || lastSwipe.action === 'right') {
return;
}
try {
const accessToken = localStorage.getItem('accessToken');
const response = await fetch('/api/bookdate/undo', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.ok) {
setLastSwipe(null);
setShowUndo(false);
// Reload recommendations to include restored card (which will be at index 0)
await loadRecommendations();
// Reset to first card (the restored card is now at the front)
setCurrentIndex(0);
}
} catch (error) {
console.error('Undo error:', error);
}
};
const handleGenerateMore = async () => {
setLoading(true);
setError(null);
try {
const accessToken = localStorage.getItem('accessToken');
const response = await fetch('/api/bookdate/generate', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to generate recommendations');
return;
}
setRecommendations(data.recommendations || []);
setCurrentIndex(0);
} catch (error: any) {
console.error('Generate error:', error);
setError(error.message || 'Failed to generate recommendations');
} finally {
setLoading(false);
}
};
// Loading state (checking onboarding or loading recommendations)
if (loading || checkingOnboarding) {
return <LoadingScreen />;
}
// Onboarding state - show settings modal only
if (isOnboarding) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header />
<div className="flex flex-col items-center justify-center min-h-[80vh] px-4">
<div className="text-center max-w-md">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
Welcome to BookDate!
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Let's customize your recommendations to get started
</p>
</div>
</div>
{/* Settings Widget */}
<SettingsWidget
isOpen={showSettings}
onClose={() => setShowSettings(false)}
isOnboarding={isOnboarding}
onOnboardingComplete={handleOnboardingComplete}
/>
</div>
);
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header />
<div className="flex flex-col items-center justify-center min-h-[80vh] px-4">
<div className="text-center max-w-md">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
⚠️ Could not load recommendations
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{error}
</p>
<div className="flex gap-4 justify-center">
<button
onClick={loadRecommendations}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Try Again
</button>
<button
onClick={() => router.push('/settings')}
className="px-6 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Go to Settings
</button>
</div>
</div>
</div>
</div>
);
}
// Empty state - no recommendations
if (recommendations.length === 0 || currentIndex >= recommendations.length) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header />
<div className="flex flex-col items-center justify-center min-h-[80vh] px-4">
<div className="text-center max-w-md">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
🎉 You've seen all our current recommendations!
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Want more suggestions based on your preferences?
</p>
<div className="flex gap-4 justify-center">
<button
onClick={handleGenerateMore}
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Get More Recommendations
</button>
<button
onClick={() => router.push('/')}
className="px-6 py-3 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Go Home
</button>
</div>
</div>
</div>
</div>
);
}
const currentRec = recommendations[currentIndex];
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header />
<main className="flex flex-col items-center justify-center min-h-[calc(100vh-80px)] p-2 md:p-4">
{/* Settings button */}
<button
onClick={() => setShowSettings(true)}
className="fixed top-20 right-4 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 shadow-lg transition-all z-10"
aria-label="Open settings"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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.065 2.572c1.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.572 1.065c-.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.065-2.572c-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>
</button>
{/* Progress indicator */}
<div className="mb-2 md:mb-4 text-sm text-gray-600 dark:text-gray-400">
{currentIndex + 1} / {recommendations.length}
</div>
{/* Recommendation card */}
<RecommendationCard
recommendation={currentRec}
onSwipe={handleSwipe}
/>
{/* Undo button */}
{showUndo && lastSwipe && (
<button
onClick={handleUndo}
className="fixed bottom-4 md:bottom-8 left-4 md:left-8 px-4 md:px-6 py-2 md:py-3 bg-gray-800 hover:bg-gray-900 text-white rounded-lg font-medium shadow-lg transition-all animate-fade-in text-sm md:text-base"
>
{lastSwipe.action === 'left' ? 'Undo Dislike' : 'Undo Dismiss'}
</button>
)}
{/* Mobile swipe hint - more compact on mobile */}
<div className="mt-2 md:mt-6 text-center text-xs md:text-sm text-gray-500 dark:text-gray-400 md:hidden">
<p>Swipe left to reject, right to request, up to dismiss</p>
</div>
</main>
{/* Settings Widget */}
<SettingsWidget
isOpen={showSettings}
onClose={() => setShowSettings(false)}
isOnboarding={isOnboarding}
onOnboardingComplete={handleOnboardingComplete}
/>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+41
View File
@@ -0,0 +1,41 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Component: Root Layout
* Documentation: documentation/frontend/components.md
*/
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { AuthProvider } from "@/contexts/AuthContext";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "ReadMeABook - Audiobook Library Management",
description: "Self-hosted audiobook library management system with Plex integration",
manifest: "/manifest.json",
icons: {
icon: [
{ url: "/rmab_icon.ico", sizes: "any" },
{ url: "/rmab_icon.ico", type: "image/x-icon" },
],
shortcut: "/rmab_icon.ico",
apple: [
{ url: "/RMAB_1024x1024.png", sizes: "1024x1024", type: "image/png" },
],
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "ReadMeABook",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100`}
>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}
+908
View File
@@ -0,0 +1,908 @@
/**
* Component: Login Page
* Documentation: documentation/frontend/pages/login.md
*/
'use client';
import { Suspense, useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/Button';
import { AlertModal } from '@/components/ui/AlertModal';
import Image from 'next/image';
interface BookCover {
asin: string;
title: string;
author: string;
coverUrl: string;
}
function LoginContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { user, login, setAuthData, isLoading: authLoading } = useAuth();
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showAdminLogin, setShowAdminLogin] = useState(false);
const [adminUsername, setAdminUsername] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [bookCovers, setBookCovers] = useState<BookCover[]>([]);
const [isMobile, setIsMobile] = useState(false);
const [authProviders, setAuthProviders] = useState<{
backendMode: string;
providers: string[];
registrationEnabled: boolean;
oidcProviderName: string | null;
} | null>(null);
const [showRegisterForm, setShowRegisterForm] = useState(false);
const [registerUsername, setRegisterUsername] = useState('');
const [registerPassword, setRegisterPassword] = useState('');
const [registerConfirmPassword, setRegisterConfirmPassword] = useState('');
const [alertModal, setAlertModal] = useState<{
isOpen: boolean;
title: string;
message: string;
variant: 'info' | 'warning' | 'success' | 'danger';
}>({ isOpen: false, title: '', message: '', variant: 'info' });
// Detect mobile viewport
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Fetch auth providers
useEffect(() => {
const fetchProviders = async () => {
try {
const response = await fetch('/api/auth/providers');
if (response.ok) {
const data = await response.json();
setAuthProviders(data);
}
} catch (err) {
console.error('Failed to fetch auth providers:', err);
// Default to Plex mode
setAuthProviders({
backendMode: 'plex',
providers: ['plex'],
registrationEnabled: false,
oidcProviderName: null,
});
}
};
fetchProviders();
}, []);
// Fetch random popular book covers
useEffect(() => {
const fetchCovers = async () => {
try {
const response = await fetch('/api/audiobooks/covers');
if (response.ok) {
const data = await response.json();
if (data.success && data.covers.length > 0) {
setBookCovers(data.covers);
}
}
} catch (err) {
console.error('Failed to fetch book covers:', err);
// Silently fail - page will show without covers
}
};
fetchCovers();
}, []);
// Redirect if already logged in
useEffect(() => {
if (user && !authLoading) {
const redirect = searchParams.get('redirect') || '/';
router.push(redirect);
}
}, [user, authLoading, router, searchParams]);
// Handle Plex OAuth callback (mobile redirect with cookies or URL hash)
useEffect(() => {
const authSuccess = searchParams.get('auth');
console.log('[Mobile Auth] useEffect triggered:', { authSuccess, hasUser: !!user, authLoading });
if (authSuccess === 'success' && !user && !authLoading) {
console.log('[Mobile Auth] Processing auth success...');
// First, try to read from URL hash (more reliable for mobile)
const hash = window.location.hash;
console.log('[Mobile Auth] URL hash:', hash);
if (hash && hash.includes('authData=')) {
try {
const authDataMatch = hash.match(/authData=([^&]+)/);
if (authDataMatch) {
const authDataStr = decodeURIComponent(authDataMatch[1]);
const authData = JSON.parse(authDataStr);
console.log('[Mobile Auth] Successfully parsed authData from URL hash:', authData.user);
// Store in localStorage
localStorage.setItem('accessToken', authData.accessToken);
localStorage.setItem('refreshToken', authData.refreshToken);
localStorage.setItem('user', JSON.stringify(authData.user));
console.log('[Mobile Auth] Stored tokens in localStorage from hash');
// Update auth context
setAuthData(authData.user, authData.accessToken);
console.log('[Mobile Auth] Updated AuthContext from hash');
// Clear the hash from URL for security
window.history.replaceState(null, '', window.location.pathname + window.location.search);
// Redirect to home
const redirect = searchParams.get('redirect') || '/';
console.log('[Mobile Auth] Redirecting to:', redirect);
router.push(redirect);
return;
}
} catch (err) {
console.error('[Mobile Auth] Failed to parse auth data from URL hash:', err);
}
}
// Fallback: Try to read from cookies
console.log('[Mobile Auth] No hash data, trying cookies...');
console.log('[Mobile Auth] All cookies:', document.cookie);
const getCookie = (name: string) => {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop()?.split(';').shift();
return null;
};
const accessToken = getCookie('accessToken');
const userDataStr = getCookie('userData');
console.log('[Mobile Auth] Cookie values:', {
hasAccessToken: !!accessToken,
accessTokenLength: accessToken?.length,
hasUserData: !!userDataStr,
userDataLength: userDataStr?.length,
});
if (accessToken && userDataStr) {
try {
console.log('[Mobile Auth] Attempting to parse userData from cookies...');
const userData = JSON.parse(decodeURIComponent(userDataStr));
console.log('[Mobile Auth] Successfully parsed userData:', userData);
// Store in localStorage for AuthContext
localStorage.setItem('accessToken', accessToken);
const refreshToken = getCookie('refreshToken');
if (refreshToken) {
localStorage.setItem('refreshToken', refreshToken);
}
localStorage.setItem('user', JSON.stringify(userData));
console.log('[Mobile Auth] Stored tokens in localStorage from cookies');
// Update auth context
setAuthData(userData, accessToken);
console.log('[Mobile Auth] Updated AuthContext from cookies');
// Redirect to home
const redirect = searchParams.get('redirect') || '/';
console.log('[Mobile Auth] Redirecting to:', redirect);
router.push(redirect);
} catch (err) {
console.error('[Mobile Auth] Failed to parse auth data from cookies:', err);
console.error('[Mobile Auth] userDataStr was:', userDataStr);
setError('Login failed. Please try again.');
}
} else {
console.warn('[Mobile Auth] Missing required cookies and hash data');
setError('Authentication failed. Please try again.');
}
}
}, [searchParams, user, authLoading, setAuthData, router]);
const handlePlexLogin = async () => {
setIsLoggingIn(true);
setError(null);
try {
// Request PIN from Plex
const response = await fetch('/api/auth/plex/login', {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to initiate login');
}
const { pinId, authUrl } = await response.json();
// On mobile, redirect to Plex OAuth instead of using popup
// The callback route will set cookies and redirect back to /login?auth=success
if (isMobile) {
window.location.href = authUrl;
return;
}
// Desktop: Open Plex OAuth in popup
const authWindow = window.open(
authUrl,
'plex-auth',
'width=600,height=700,scrollbars=yes,resizable=yes'
);
if (!authWindow) {
setError('Popup was blocked. Please allow popups for this site and try again.');
setIsLoggingIn(false);
return;
}
// Poll for authorization
await login(pinId);
// Close popup
authWindow.close();
// Redirect to intended page or homepage
const redirect = searchParams.get('redirect') || '/';
router.push(redirect);
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed. Please try again.');
} finally {
setIsLoggingIn(false);
}
};
const handleAdminLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoggingIn(true);
setError(null);
try {
// Use local login endpoint for ABS mode with local auth, admin login endpoint for Plex mode
const loginEndpoint = authProviders?.providers.includes('local')
? '/api/auth/local/login'
: '/api/auth/admin/login';
const response = await fetch(loginEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: adminUsername,
password: adminPassword,
}),
});
const data = await response.json();
// Check if account is pending approval (can be 200 or error status)
if (data.pendingApproval) {
setAlertModal({
isOpen: true,
title: 'Account Pending Approval',
message: 'Your account is awaiting administrator approval. You will be able to log in once your registration has been approved.',
variant: 'warning',
});
return;
}
if (!response.ok) {
throw new Error(data.error || data.message || 'Login failed');
}
// Store tokens
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
localStorage.setItem('user', JSON.stringify(data.user));
// Update auth context immediately
setAuthData(data.user, data.accessToken);
// Redirect to intended page or homepage
const redirect = searchParams.get('redirect') || '/';
router.push(redirect);
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed. Please try again.');
} finally {
setIsLoggingIn(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoggingIn(true);
setError(null);
// Validation
if (registerPassword !== registerConfirmPassword) {
setError('Passwords do not match');
setIsLoggingIn(false);
return;
}
if (registerPassword.length < 8) {
setError('Password must be at least 8 characters');
setIsLoggingIn(false);
return;
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: registerUsername,
password: registerPassword,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Registration failed');
}
// Check if pending approval
if (data.pendingApproval) {
setError(null);
setShowRegisterForm(false);
setAlertModal({
isOpen: true,
title: 'Registration Pending',
message: 'Your account has been created successfully! An administrator needs to approve your registration before you can log in. You will be notified once your account is approved.',
variant: 'warning',
});
return;
}
// Auto-login after successful registration
if (data.success && data.accessToken) {
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
localStorage.setItem('user', JSON.stringify(data.user));
// Update auth context
setAuthData(data.user, data.accessToken);
// Redirect to intended page or homepage
const redirect = searchParams.get('redirect') || '/';
router.push(redirect);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed. Please try again.');
} finally {
setIsLoggingIn(false);
}
};
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="text-white">Loading...</div>
</div>
);
}
// Generate random positions for covers
const generateCoverPosition = (index: number, total: number) => {
// Create a seeded random for consistent positions per index
const random = (seed: number) => {
const x = Math.sin(seed) * 10000;
return x - Math.floor(x);
};
// Different animation types
const animations = ['animate-float-slow', 'animate-float-medium', 'animate-float-fast'];
const animation = animations[index % 3];
// Random size between 80-160px
const size = 80 + random(index * 7) * 80;
// Random position (0-100% for both axes)
const top = random(index * 13) * 100;
const left = random(index * 17) * 100;
// Random opacity (0.15-0.35 for subtle layering)
const opacity = 0.15 + random(index * 23) * 0.2;
// Random delay (0-10s)
const delay = random(index * 29) * 10;
// Layer depth (z-index) - some in front, some behind
const zIndex = Math.floor(random(index * 31) * 20);
return {
top: `${top}%`,
left: `${left}%`,
size: Math.floor(size),
animation,
delay: `${delay.toFixed(1)}s`,
opacity: parseFloat(opacity.toFixed(2)),
zIndex,
};
};
return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-blue-900 to-gray-900 relative overflow-hidden">
{/* Floating audiobook covers background */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{bookCovers.length > 0 ? (
<>
{/* Floating real book covers - use fewer on mobile (30) vs desktop (100) for better performance */}
{bookCovers.slice(0, isMobile ? 30 : 100).map((book, index) => {
const pos = generateCoverPosition(index, bookCovers.length);
const style: React.CSSProperties = {
animationDelay: pos.delay,
opacity: pos.opacity,
zIndex: pos.zIndex,
};
return (
<div
key={book.asin}
className={`absolute ${pos.animation}`}
style={{
...style,
top: pos.top,
left: pos.left,
width: `${pos.size}px`,
height: `${pos.size * 1.5}px`,
}}
>
<div className="relative w-full h-full rounded-lg shadow-2xl overflow-hidden transform hover:scale-105 transition-transform duration-300">
<Image
src={book.coverUrl}
alt={book.title}
fill
className="object-cover"
sizes={`${pos.size}px`}
quality={70}
priority={index < 10}
loading={index < 10 ? 'eager' : 'lazy'}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/40 to-transparent" />
</div>
</div>
);
})}
</>
) : (
<>
{/* Fallback decorative floating elements if covers don't load */}
<div className="absolute top-10 left-10 w-32 h-48 bg-blue-500/10 rounded-lg animate-float-slow" />
<div className="absolute top-40 right-20 w-28 h-40 bg-purple-500/10 rounded-lg animate-float-medium" />
<div className="absolute bottom-20 left-1/4 w-36 h-52 bg-indigo-500/10 rounded-lg animate-float-fast" />
<div className="absolute top-1/3 right-1/3 w-24 h-36 bg-pink-500/10 rounded-lg animate-float-slow" />
<div className="absolute bottom-40 right-10 w-30 h-44 bg-cyan-500/10 rounded-lg animate-float-medium" />
</>
)}
</div>
{/* Main content - high z-index to appear above all floating covers */}
<main className="relative z-50 min-h-screen flex items-center justify-center px-4 py-8">
<div className="max-w-md w-full">
{/* Login card */}
<div className="bg-gray-900/80 backdrop-blur-md rounded-2xl shadow-2xl p-6 sm:p-8 md:p-12 border border-gray-700/50">
{/* Logo/Title */}
<div className="text-center mb-6 sm:mb-8">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-white mb-2 sm:mb-3">
ReadMeABook
</h1>
<p className="text-gray-300 text-base sm:text-lg">
Your Personal Audiobook Library Manager
</p>
</div>
{/* Description */}
<div className="mb-6 sm:mb-8 text-center">
<p className="text-gray-400 text-sm sm:text-base">
Request audiobooks and they'll automatically download and appear in your Plex library
</p>
</div>
{/* Error message */}
{error && (
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/50 rounded-lg">
<p className="text-red-400 text-sm">{error}</p>
</div>
)}
{!authProviders ? (
<div className="text-center text-gray-400">Loading...</div>
) : (
<>
{/* Plex Login button */}
{authProviders.providers.includes('plex') && (
<>
<Button
onClick={handlePlexLogin}
disabled={isLoggingIn}
loading={isLoggingIn}
className="w-full text-base sm:text-lg py-3 sm:py-4 bg-orange-600 hover:bg-orange-700 text-white font-semibold"
>
{isLoggingIn ? 'Connecting to Plex...' : 'Login with Plex'}
</Button>
<div className="mt-4 sm:mt-6 text-center text-xs sm:text-sm text-gray-500">
<p>You'll be redirected to Plex to authorize this application</p>
</div>
</>
)}
{/* OIDC Login button */}
{authProviders.providers.includes('oidc') && authProviders.oidcProviderName && (
<>
<Button
onClick={() => window.location.href = '/api/auth/oidc/login'}
disabled={isLoggingIn}
className="w-full text-base sm:text-lg py-3 sm:py-4 bg-blue-600 hover:bg-blue-700 text-white font-semibold"
>
Login with {authProviders.oidcProviderName}
</Button>
<div className="mt-4 sm:mt-6 text-center text-xs sm:text-sm text-gray-500">
<p>You'll be redirected to {authProviders.oidcProviderName} to authenticate</p>
</div>
</>
)}
{/* Divider if we have both OIDC and local auth */}
{authProviders.providers.includes('oidc') && authProviders.providers.includes('local') && (
<div className="relative my-6 sm:my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-gray-900/80 text-gray-400">or</span>
</div>
</div>
)}
{/* Local auth login/register form */}
{authProviders.providers.includes('local') && (
<>
{showRegisterForm ? (
/* Registration Form */
<form onSubmit={handleRegister} className="space-y-4">
<div className="text-center mb-4">
<h3 className="text-xl font-semibold text-white">Create Account</h3>
</div>
<div>
<label htmlFor="register-username" className="block text-sm font-medium text-gray-300 mb-2">
Username
</label>
<input
type="text"
id="register-username"
value={registerUsername}
onChange={(e) => setRegisterUsername(e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="Choose a username"
required
minLength={3}
autoComplete="username"
/>
<p className="text-xs text-gray-500 mt-1">At least 3 characters</p>
</div>
<div>
<label htmlFor="register-password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
type="password"
id="register-password"
value={registerPassword}
onChange={(e) => setRegisterPassword(e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••"
required
minLength={8}
autoComplete="new-password"
/>
<p className="text-xs text-gray-500 mt-1">At least 8 characters</p>
</div>
<div>
<label htmlFor="register-confirm-password" className="block text-sm font-medium text-gray-300 mb-2">
Confirm Password
</label>
<input
type="password"
id="register-confirm-password"
value={registerConfirmPassword}
onChange={(e) => setRegisterConfirmPassword(e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••"
required
minLength={8}
autoComplete="new-password"
/>
</div>
<Button
type="submit"
disabled={isLoggingIn}
loading={isLoggingIn}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold"
>
{isLoggingIn ? 'Creating Account...' : 'Register'}
</Button>
{authProviders.registrationEnabled && (
<div className="text-center">
<button
type="button"
onClick={() => {
setShowRegisterForm(false);
setError(null);
setRegisterUsername('');
setRegisterPassword('');
setRegisterConfirmPassword('');
}}
className="text-sm text-gray-400 hover:text-gray-300 transition-colors"
>
Already have an account? Login
</button>
</div>
)}
</form>
) : (
/* Login Form */
<form onSubmit={handleAdminLogin} className="space-y-4">
<div>
<label htmlFor="admin-username" className="block text-sm font-medium text-gray-300 mb-2">
Username
</label>
<input
type="text"
id="admin-username"
value={adminUsername}
onChange={(e) => setAdminUsername(e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="username"
required
autoComplete="username"
/>
</div>
<div>
<label htmlFor="admin-password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
type="password"
id="admin-password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••"
required
autoComplete="current-password"
/>
</div>
<Button
type="submit"
disabled={isLoggingIn}
loading={isLoggingIn}
className="w-full bg-gray-700 hover:bg-gray-600 text-white font-semibold"
>
{isLoggingIn ? 'Logging in...' : 'Login'}
</Button>
{authProviders.registrationEnabled && (
<div className="text-center">
<button
type="button"
onClick={() => {
setShowRegisterForm(true);
setError(null);
setAdminUsername('');
setAdminPassword('');
}}
className="text-sm text-gray-400 hover:text-gray-300 transition-colors"
>
Don't have an account? Register
</button>
</div>
)}
</form>
)}
</>
)}
{/* Admin Login toggle for Plex mode */}
{authProviders.providers.includes('plex') && !authProviders.providers.includes('local') && (
<>
<div className="relative my-6 sm:my-8">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-gray-900/80 text-gray-400">or</span>
</div>
</div>
<button
onClick={() => setShowAdminLogin(!showAdminLogin)}
className="w-full text-sm text-gray-400 hover:text-gray-300 transition-colors py-2 flex items-center justify-center gap-2"
>
{showAdminLogin ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
Hide Admin Login
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Admin Login
</>
)}
</button>
{showAdminLogin && (
<form onSubmit={handleAdminLogin} className="mt-6 space-y-4">
<div>
<label htmlFor="admin-username" className="block text-sm font-medium text-gray-300 mb-2">
Username
</label>
<input
type="text"
id="admin-username"
value={adminUsername}
onChange={(e) => setAdminUsername(e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="admin"
required
autoComplete="username"
/>
</div>
<div>
<label htmlFor="admin-password" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
type="password"
id="admin-password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••"
required
autoComplete="current-password"
/>
</div>
<Button
type="submit"
disabled={isLoggingIn}
loading={isLoggingIn}
className="w-full bg-gray-700 hover:bg-gray-600 text-white font-semibold"
>
{isLoggingIn ? 'Logging in...' : 'Login as Admin'}
</Button>
</form>
)}
</>
)}
</>
)}
</div>
{/* Footer info */}
<div className="mt-8 text-center text-sm text-gray-500">
<p>
Powered by{' '}
<a
href="https://www.plex.tv"
target="_blank"
rel="noopener noreferrer"
className="text-orange-400 hover:text-orange-300 transition-colors"
>
Plex
</a>
{' '}&{' '}
<a
href="https://www.audible.com"
target="_blank"
rel="noopener noreferrer"
className="text-orange-400 hover:text-orange-300 transition-colors"
>
Audible
</a>
</p>
</div>
</div>
</main>
{/* Alert Modal */}
<AlertModal
isOpen={alertModal.isOpen}
onClose={() => setAlertModal({ ...alertModal, isOpen: false })}
title={alertModal.title}
message={alertModal.message}
variant={alertModal.variant}
/>
{/* CSS animations for floating book covers */}
<style jsx>{`
@keyframes float-slow {
0%, 100% {
transform: translateY(0px) translateX(0px) rotate(0deg) scale(1);
}
25% {
transform: translateY(-25px) translateX(15px) rotate(2deg) scale(1.03);
}
50% {
transform: translateY(-35px) translateX(25px) rotate(4deg) scale(1.05);
}
75% {
transform: translateY(-20px) translateX(-10px) rotate(-2deg) scale(1.02);
}
}
@keyframes float-medium {
0%, 100% {
transform: translateY(0px) translateX(0px) rotate(0deg) scale(1);
}
33% {
transform: translateY(-30px) translateX(-20px) rotate(-3deg) scale(1.04);
}
66% {
transform: translateY(-15px) translateX(10px) rotate(3deg) scale(1.02);
}
}
@keyframes float-fast {
0%, 100% {
transform: translateY(0px) translateX(0px) rotate(0deg) scale(1);
}
50% {
transform: translateY(-28px) translateX(18px) rotate(5deg) scale(1.06);
}
}
.animate-float-slow {
animation: float-slow 22s ease-in-out infinite;
filter: blur(0px);
transition: filter 0.3s ease, transform 0.3s ease;
}
.animate-float-medium {
animation: float-medium 16s ease-in-out infinite;
filter: blur(0px);
transition: filter 0.3s ease, transform 0.3s ease;
}
.animate-float-fast {
animation: float-fast 12s ease-in-out infinite;
filter: blur(0px);
transition: filter 0.3s ease, transform 0.3s ease;
}
.animate-float-slow:hover,
.animate-float-medium:hover,
.animate-float-fast:hover {
animation-play-state: paused;
filter: blur(0px);
}
`}</style>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-900">
<div className="text-white">Loading...</div>
</div>
}>
<LoginContent />
</Suspense>
);
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Component: Homepage - Audiobook Discovery
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState, useRef } from 'react';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { useAudiobooks } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { StickyPagination } from '@/components/ui/StickyPagination';
export default function HomePage() {
const [popularPage, setPopularPage] = useState(1);
const [newReleasesPage, setNewReleasesPage] = useState(1);
// Refs for auto-scrolling to section tops
const popularSectionRef = useRef<HTMLElement>(null);
const newReleasesSectionRef = useRef<HTMLElement>(null);
const {
audiobooks: popular,
isLoading: loadingPopular,
totalPages: popularTotalPages,
message: popularMessage,
} = useAudiobooks('popular', 20, popularPage);
const {
audiobooks: newReleases,
isLoading: loadingNewReleases,
totalPages: newReleasesTotalPages,
message: newReleasesMessage,
} = useAudiobooks('new-releases', 20, newReleasesPage);
// Handle page changes with auto-scroll to section top
const handlePopularPageChange = (page: number) => {
setPopularPage(page);
popularSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleNewReleasesPageChange = (page: number) => {
setNewReleasesPage(page);
newReleasesSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-8 sm:space-y-12">
{/* Popular Audiobooks Section */}
<section ref={popularSectionRef} className="relative">
{/* Sticky Section Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-blue-500 to-purple-500 rounded-full" />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100">
Popular Audiobooks
</h2>
</div>
</div>
</div>
{/* Section Content */}
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
{popularMessage && !loadingPopular && popular.length === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
No popular audiobooks found
</p>
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
{popularMessage}
</p>
</div>
) : (
<AudiobookGrid
audiobooks={popular}
isLoading={loadingPopular}
emptyMessage="No popular audiobooks available"
/>
)}
</div>
</section>
{/* New Releases Section */}
<section ref={newReleasesSectionRef} className="relative">
{/* Sticky Section Header */}
<div className="sticky top-14 sm:top-16 z-30 mb-4 sm:mb-6">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-2xl px-4 sm:px-6 py-3 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-1 h-6 bg-gradient-to-b from-emerald-500 to-teal-500 rounded-full" />
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900 dark:text-gray-100">
New Releases
</h2>
</div>
</div>
</div>
{/* Section Content */}
<div className="bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm rounded-2xl p-4 sm:p-6 border border-gray-200/50 dark:border-gray-700/50 shadow-sm">
{newReleasesMessage && !loadingNewReleases && newReleases.length === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 text-center">
<p className="text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
No new releases found
</p>
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
{newReleasesMessage}
</p>
</div>
) : (
<AudiobookGrid
audiobooks={newReleases}
isLoading={loadingNewReleases}
emptyMessage="No new releases available"
/>
)}
</div>
</section>
{/* Call to Action */}
<section className="bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-2xl p-6 sm:p-8 text-center border border-blue-200/50 dark:border-blue-800/50 shadow-sm">
<h3 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Can't find what you're looking for?
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Use our search to find any audiobook from Audible
</p>
<a
href="/search"
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg"
>
Search Audiobooks
</a>
</section>
</main>
{/* Footer */}
<footer className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-16">
<div className="container mx-auto px-4 py-6 max-w-7xl">
<div className="text-center text-sm text-gray-600 dark:text-gray-400">
<p>ReadMeABook - Audiobook Library Management System</p>
<p className="mt-1">
Powered by{' '}
<a
href="https://www.plex.tv"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Plex
</a>
{' '}&{' '}
<a
href="https://www.audible.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
Audible
</a>
</p>
</div>
</div>
</footer>
{/* Sticky Pagination Controls */}
<StickyPagination
currentPage={popularPage}
totalPages={popularTotalPages}
onPageChange={handlePopularPageChange}
sectionRef={popularSectionRef}
label="Popular Audiobooks"
/>
<StickyPagination
currentPage={newReleasesPage}
totalPages={newReleasesTotalPages}
onPageChange={handleNewReleasesPageChange}
sectionRef={newReleasesSectionRef}
label="New Releases"
/>
</div>
</ProtectedRoute>
);
}
+367
View File
@@ -0,0 +1,367 @@
/**
* Component: User Profile Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useMemo } from 'react';
import { Header } from '@/components/layout/Header';
import { RequestCard } from '@/components/requests/RequestCard';
import { useAuth } from '@/contexts/AuthContext';
import { useRequests } from '@/lib/hooks/useRequests';
import { cn } from '@/lib/utils/cn';
export default function ProfilePage() {
const { user } = useAuth();
// Always show only the current user's own requests (even for admins)
const { requests, isLoading } = useRequests(undefined, 50, true);
// Calculate statistics
const stats = useMemo(() => {
if (!requests.length) {
return {
total: 0,
completed: 0,
active: 0,
waiting: 0,
failed: 0,
cancelled: 0,
};
}
return {
total: requests.length,
completed: requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length,
active: requests.filter((r: any) =>
['pending', 'searching', 'downloading', 'processing'].includes(r.status)
).length,
waiting: requests.filter((r: any) =>
['awaiting_search', 'awaiting_import'].includes(r.status)
).length,
failed: requests.filter((r: any) => r.status === 'failed').length,
cancelled: requests.filter((r: any) => r.status === 'cancelled').length,
};
}, [requests]);
// Get active downloads (downloading or processing)
const activeDownloads = useMemo(() => {
return requests.filter((r: any) =>
['downloading', 'processing'].includes(r.status)
);
}, [requests]);
// Get recent requests (last 5)
const recentRequests = useMemo(() => {
return [...requests]
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5);
}, [requests]);
// Redirect to login if not authenticated
if (!user) {
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
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>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Authentication Required
</h2>
<p className="text-gray-600 dark:text-gray-400">
Please log in to view your profile
</p>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
{/* User Info Card */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
{/* Avatar */}
<div className="flex-shrink-0">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.username}
className="w-24 h-24 rounded-full"
/>
) : (
<div className="w-24 h-24 rounded-full bg-blue-600 flex items-center justify-center text-white text-3xl font-bold">
{user.username.charAt(0).toUpperCase()}
</div>
)}
</div>
{/* User Details */}
<div className="flex-1 space-y-2 text-center sm:text-left">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
{user.username}
</h1>
{user.email && (
<p className="text-gray-600 dark:text-gray-400">
{user.email}
</p>
)}
<div className="flex items-center gap-2">
<span
className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium',
user.role === 'admin'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
)}
>
{user.role === 'admin' ? 'Administrator' : 'User'}
</span>
<span className="text-sm text-gray-500 dark:text-gray-500">
Plex ID: {user.plexId}
</span>
</div>
</div>
</div>
</div>
{/* Statistics Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
{/* Total Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Total</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
{isLoading ? '...' : stats.total}
</p>
</div>
</div>
</div>
{/* Active Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Active</p>
<p className="text-xl sm:text-2xl font-bold text-blue-600 dark:text-blue-400">
{isLoading ? '...' : stats.active}
</p>
</div>
</div>
</div>
{/* Waiting Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-yellow-100 dark:bg-yellow-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-yellow-600 dark:text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Waiting</p>
<p className="text-xl sm:text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{isLoading ? '...' : stats.waiting}
</p>
</div>
</div>
</div>
{/* Completed Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-green-100 dark:bg-green-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Completed</p>
<p className="text-xl sm:text-2xl font-bold text-green-600 dark:text-green-400">
{isLoading ? '...' : stats.completed}
</p>
</div>
</div>
</div>
{/* Failed Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-red-100 dark:bg-red-900 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Failed</p>
<p className="text-xl sm:text-2xl font-bold text-red-600 dark:text-red-400">
{isLoading ? '...' : stats.failed}
</p>
</div>
</div>
</div>
{/* Cancelled Requests */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 sm:p-6">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 sm:gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
</div>
<div>
<p className="text-xs sm:text-sm text-gray-600 dark:text-gray-400">Cancelled</p>
<p className="text-xl sm:text-2xl font-bold text-gray-600 dark:text-gray-400">
{isLoading ? '...' : stats.cancelled}
</p>
</div>
</div>
</div>
</div>
{/* Active Downloads */}
{activeDownloads.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Active Downloads
</h2>
<a
href="/requests"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View All Requests
</a>
</div>
<div className="space-y-4">
{activeDownloads.map((request: any) => (
<RequestCard key={request.id} request={request} showActions={false} />
))}
</div>
</div>
)}
{/* Recent Requests */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Recent Requests
</h2>
<a
href="/requests"
className="text-sm text-blue-600 dark:text-blue-400 hover:underline"
>
View All Requests
</a>
</div>
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
>
<div className="flex gap-4">
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div>
<div className="flex-1 space-y-3">
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></div>
</div>
</div>
</div>
))}
</div>
) : recentRequests.length > 0 ? (
<div className="space-y-4">
{recentRequests.map((request: any) => (
<RequestCard key={request.id} request={request} showActions={false} />
))}
</div>
) : (
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow-md space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<div className="space-y-2">
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
No requests yet
</h3>
<p className="text-gray-600 dark:text-gray-400">
Start by searching for audiobooks and requesting them
</p>
</div>
<div className="pt-4">
<a
href="/search"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
Search Audiobooks
</a>
</div>
</div>
)}
</div>
</main>
</div>
);
}
+217
View File
@@ -0,0 +1,217 @@
/**
* Component: Requests Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState } from 'react';
import { Header } from '@/components/layout/Header';
import { RequestCard } from '@/components/requests/RequestCard';
import { useRequests } from '@/lib/hooks/useRequests';
import { useAuth } from '@/contexts/AuthContext';
import { cn } from '@/lib/utils/cn';
type FilterStatus = 'all' | 'active' | 'waiting' | 'completed' | 'failed' | 'cancelled';
export default function RequestsPage() {
const { user } = useAuth();
const [filter, setFilter] = useState<FilterStatus>('all');
// Always fetch only the current user's requests (even for admins)
// This ensures "My Requests" truly shows only the user's own requests
// Admins can see all requests in the admin panel
const { requests, isLoading } = useRequests(undefined, 50, true);
// Filter requests client-side based on selected filter
const filteredRequests = filter === 'all'
? requests
: filter === 'active'
? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status))
: filter === 'waiting'
? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status))
: filter === 'completed'
? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status))
: requests.filter((r: any) => r.status === filter);
const filterOptions: { value: FilterStatus; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'waiting', label: 'Waiting' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'cancelled', label: 'Cancelled' },
];
// Redirect to login if not authenticated
if (!user) {
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl">
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
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>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Authentication Required
</h2>
<p className="text-gray-600 dark:text-gray-400">
Please log in to view your audiobook requests
</p>
</div>
</main>
</div>
);
}
return (
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-6 sm:py-8 max-w-7xl space-y-6 sm:space-y-8">
{/* Page Header */}
<div className="space-y-2 sm:space-y-4">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100">
My Requests
</h1>
<p className="text-sm sm:text-base text-gray-600 dark:text-gray-400">
Track the status of your audiobook requests in real-time
</p>
</div>
{/* Filter Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700 -mx-4 px-4 sm:mx-0 sm:px-0">
<div className="flex gap-2 sm:gap-4 -mb-px overflow-x-auto scrollbar-hide">
{filterOptions.map((option) => (
<button
key={option.value}
onClick={() => setFilter(option.value)}
className={cn(
'px-3 sm:px-4 py-2 sm:py-3 text-xs sm:text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
filter === option.value
? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:border-gray-300'
)}
>
{option.label}
{!isLoading && (
<span className="ml-2 text-xs">
({option.value === 'all'
? requests.length
: option.value === 'active'
? requests.filter((r: any) => ['pending', 'searching', 'downloading', 'processing'].includes(r.status)).length
: option.value === 'waiting'
? requests.filter((r: any) => ['awaiting_search', 'awaiting_import'].includes(r.status)).length
: option.value === 'completed'
? requests.filter((r: any) => ['available', 'downloaded'].includes(r.status)).length
: requests.filter((r: any) => r.status === option.value).length
})
</span>
)}
</button>
))}
</div>
</div>
{/* Loading State */}
{isLoading && (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 animate-pulse"
>
<div className="flex gap-4">
<div className="w-24 h-36 bg-gray-300 dark:bg-gray-700 rounded"></div>
<div className="flex-1 space-y-3">
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-24"></div>
</div>
</div>
</div>
))}
</div>
)}
{/* Requests List */}
{!isLoading && filteredRequests.length > 0 && (
<div className="space-y-4">
{filteredRequests.map((request: any) => (
<RequestCard key={request.id} request={request} showActions={true} />
))}
</div>
)}
{/* Empty State */}
{!isLoading && filteredRequests.length === 0 && (
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<div className="space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">
{filter === 'all' ? 'No requests yet' : `No ${filter} requests`}
</h2>
<p className="text-gray-600 dark:text-gray-400">
{filter === 'all'
? 'Start by searching for audiobooks and requesting them'
: `You don't have any ${filter} requests at the moment`
}
</p>
</div>
{filter === 'all' && (
<div className="pt-4">
<a
href="/search"
className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
Search Audiobooks
</a>
</div>
)}
</div>
)}
{/* Auto-refresh indicator */}
{!isLoading && filteredRequests.length > 0 && (
<div className="text-center text-xs text-gray-500 dark:text-gray-500 py-4">
<div className="flex items-center justify-center gap-2">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span>Auto-refreshing every 5 seconds</span>
</div>
</div>
)}
</main>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Component: Search Page
* Documentation: documentation/frontend/components.md
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Header } from '@/components/layout/Header';
import { AudiobookGrid } from '@/components/audiobooks/AudiobookGrid';
import { useSearch } from '@/lib/hooks/useAudiobooks';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
export default function SearchPage() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [page, setPage] = useState(1);
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
setPage(1); // Reset to first page on new search
}, 500);
return () => clearTimeout(timer);
}, [query]);
const { results, totalResults, hasMore, isLoading } = useSearch(debouncedQuery, page);
const handleSearch = useCallback((e: React.FormEvent) => {
e.preventDefault();
setPage(1);
}, []);
const handleLoadMore = useCallback(() => {
setPage((prev) => prev + 1);
}, []);
return (
<ProtectedRoute>
<div className="min-h-screen">
<Header />
<main className="container mx-auto px-4 py-8 max-w-7xl space-y-8">
{/* Search Header */}
<div className="text-center space-y-4">
<h1 className="text-4xl font-bold text-gray-900 dark:text-gray-100">
Search Audiobooks
</h1>
<p className="text-gray-600 dark:text-gray-400">
Find and request any audiobook from Audible
</p>
</div>
{/* Search Form */}
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg
className="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by title, author, or narrator..."
className="w-full pl-12 pr-12 py-4 text-lg border-2 border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
autoFocus
/>
{query && (
<button
type="button"
onClick={() => setQuery('')}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="h-5 w-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>
</form>
{/* Results */}
{debouncedQuery ? (
<div className="space-y-6">
{/* Results Count */}
{!isLoading && totalResults > 0 && (
<div className="text-center text-gray-600 dark:text-gray-400">
Found {totalResults.toLocaleString()} result{totalResults !== 1 ? 's' : ''} for "{debouncedQuery}"
</div>
)}
{/* Results Grid */}
<AudiobookGrid
audiobooks={results}
isLoading={!!(isLoading && page === 1)}
emptyMessage={`No results found for "${debouncedQuery}"`}
/>
{/* Load More */}
{hasMore && !isLoading && (
<div className="flex justify-center">
<button
onClick={handleLoadMore}
className="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Load More Results
</button>
</div>
)}
{/* Loading More Indicator */}
{isLoading && page > 1 && (
<div className="flex justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
)}
</div>
) : (
/* Empty State */
<div className="text-center py-16 space-y-4">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<p className="text-xl text-gray-600 dark:text-gray-400">
Start typing to search for audiobooks
</p>
<p className="text-sm text-gray-500 dark:text-gray-500">
Search by title, author, or narrator name
</p>
</div>
)}
</main>
</div>
</ProtectedRoute>
);
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Component: Setup Wizard Layout
* Documentation: documentation/setup-wizard.md
*/
'use client';
import React from 'react';
interface WizardLayoutProps {
currentStep: number;
totalSteps: number;
children: React.ReactNode;
backendMode?: 'plex' | 'audiobookshelf';
authMethod?: 'oidc' | 'manual' | 'both';
}
export function WizardLayout({ currentStep, totalSteps, children, backendMode = 'plex', authMethod = 'oidc' }: WizardLayoutProps) {
// Generate dynamic steps based on backend mode and auth method
const generateSteps = () => {
const steps: { number: number; title: string }[] = [
{ number: 1, title: 'Welcome' },
{ number: 2, title: 'Backend' },
];
if (backendMode === 'plex') {
steps.push(
{ number: 3, title: 'Admin' },
{ number: 4, title: 'Plex' },
{ number: 5, title: 'Prowlarr' },
{ number: 6, title: 'Download' },
{ number: 7, title: 'Paths' },
{ number: 8, title: 'BookDate' },
{ number: 9, title: 'Review' },
{ number: 10, title: 'Finalize' }
);
} else {
// Audiobookshelf mode
let stepNumber = 3;
steps.push({ number: stepNumber++, title: 'ABS' });
steps.push({ number: stepNumber++, title: 'Auth' });
if (authMethod === 'oidc' || authMethod === 'both') {
steps.push({ number: stepNumber++, title: 'OIDC' });
}
if (authMethod === 'manual' || authMethod === 'both') {
steps.push({ number: stepNumber++, title: 'Registration' });
steps.push({ number: stepNumber++, title: 'Admin' });
}
steps.push(
{ number: stepNumber++, title: 'Prowlarr' },
{ number: stepNumber++, title: 'Download' },
{ number: stepNumber++, title: 'Paths' },
{ number: stepNumber++, title: 'BookDate' },
{ number: stepNumber++, title: 'Review' },
{ number: stepNumber++, title: 'Finalize' }
);
}
return steps;
};
const steps = generateSteps();
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
<div className="container mx-auto px-4 py-6 max-w-4xl">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
ReadMeABook Setup
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Configure your audiobook automation system
</p>
</div>
</div>
{/* Progress Bar */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="container mx-auto px-2 sm:px-4 max-w-4xl">
<div className="flex items-center justify-between py-4 overflow-x-auto">
{steps.map((step, index) => (
<React.Fragment key={step.number}>
{/* Step Circle */}
<div className="flex flex-col items-center flex-1">
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center font-semibold
${
step.number < currentStep
? 'bg-green-500 text-white'
: step.number === currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
}
`}
>
{step.number < currentStep ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
step.number
)}
</div>
<span
className={`
text-xs mt-2 text-center whitespace-nowrap
${
step.number === currentStep
? 'text-blue-600 dark:text-blue-400 font-medium'
: 'text-gray-600 dark:text-gray-400'
}
`}
>
{step.title}
</span>
</div>
{/* Connecting Line */}
{index < steps.length - 1 && (
<div
className={`
h-1 flex-1 mx-1 rounded
${
step.number < currentStep
? 'bg-green-500'
: 'bg-gray-200 dark:bg-gray-700'
}
`}
/>
)}
</React.Fragment>
))}
</div>
</div>
</div>
{/* Main Content */}
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-8">
{children}
</div>
</div>
{/* Footer */}
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 py-4">
<div className="container mx-auto px-4 max-w-4xl">
<p className="text-sm text-center text-gray-600 dark:text-gray-400">
Step {currentStep} of {totalSteps}
</p>
</div>
</div>
</div>
);
}
+402
View File
@@ -0,0 +1,402 @@
/**
* Component: Setup Initializing Page (First Login)
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
interface JobStatus {
id: string;
name: string;
description: string;
status: 'pending' | 'running' | 'completed' | 'error';
error?: string;
}
export default function InitializingPage() {
const router = useRouter();
const [jobs, setJobs] = useState<JobStatus[]>([
{
id: 'audible_refresh',
name: 'Audible Data Refresh',
description: 'Fetching popular and new release audiobooks to populate your browse catalog',
status: 'pending',
},
{
id: 'plex_library_scan',
name: 'Library Scan',
description: 'Scanning your media library to discover audiobooks you already have',
status: 'pending',
},
]);
const [isComplete, setIsComplete] = useState(false);
const [hasStarted, setHasStarted] = useState(false);
const [authProcessed, setAuthProcessed] = useState(false);
// Process auth data from URL hash and start monitoring jobs
useEffect(() => {
if (authProcessed) return;
// Read auth data from URL hash
const hash = window.location.hash;
if (!hash || !hash.includes('authData=')) {
console.error('[Initializing] No auth data in URL hash');
router.push('/login?error=' + encodeURIComponent('Authentication data missing'));
return;
}
try {
const authDataMatch = hash.match(/authData=([^&]+)/);
if (!authDataMatch) {
throw new Error('Failed to parse auth data');
}
const authDataStr = decodeURIComponent(authDataMatch[1]);
const authData = JSON.parse(authDataStr);
// Store in localStorage
localStorage.setItem('accessToken', authData.accessToken);
localStorage.setItem('refreshToken', authData.refreshToken);
localStorage.setItem('user', JSON.stringify(authData.user));
console.log('[Initializing] Auth data stored, starting job monitoring');
// Clear hash for security
window.history.replaceState(null, '', window.location.pathname);
setAuthProcessed(true);
// Start monitoring jobs after a brief delay to ensure jobs have been triggered
setTimeout(() => {
runJobs(authData.accessToken);
}, 2000);
} catch (error) {
console.error('[Initializing] Failed to process auth data:', error);
router.push('/login?error=' + encodeURIComponent('Failed to process authentication'));
}
}, [authProcessed, router]);
const pollJobStatus = async (jobId: string, accessToken: string): Promise<'completed' | 'failed'> => {
console.log(`[Initializing] Polling job status for: ${jobId}`);
return new Promise((resolve) => {
const pollInterval = setInterval(async () => {
try {
const response = await fetch(`/api/admin/job-status/${jobId}`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
console.error(`[Initializing] Failed to fetch job status: ${response.status}`);
throw new Error('Failed to fetch job status');
}
const data = await response.json();
const jobStatus = data.job.status;
console.log(`[Initializing] Job ${jobId} status: ${jobStatus}`);
if (jobStatus === 'completed') {
clearInterval(pollInterval);
resolve('completed');
} else if (jobStatus === 'failed') {
clearInterval(pollInterval);
resolve('failed');
}
// Otherwise keep polling
} catch (error) {
console.error('[Initializing] Error polling job status:', error);
clearInterval(pollInterval);
resolve('failed');
}
}, 2000); // Poll every 2 seconds
// Timeout after 10 minutes
setTimeout(() => {
clearInterval(pollInterval);
resolve('failed');
}, 10 * 60 * 1000);
});
};
const runJobs = async (accessToken: string) => {
if (hasStarted) return;
setHasStarted(true);
console.log('[Initializing] Starting job monitoring');
// Get all scheduled jobs to find the IDs
let scheduledJobs: any[] = [];
try {
const response = await fetch('/api/admin/jobs', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch scheduled jobs');
}
const data = await response.json();
scheduledJobs = data.jobs;
} catch (error) {
console.error('[Initializing] Failed to fetch scheduled jobs:', error);
setJobs(prev => prev.map(job => ({
...job,
status: 'error',
error: 'Failed to fetch job configuration',
})));
setIsComplete(true);
return;
}
// Monitor each job
for (let i = 0; i < jobs.length; i++) {
const job = jobs[i];
// Update status to running
setJobs(prev => prev.map((j, idx) =>
idx === i ? { ...j, status: 'running' } : j
));
// Find the scheduled job by type
const scheduledJob = scheduledJobs.find((sj: any) => sj.type === job.id);
if (!scheduledJob) {
console.error(`[Initializing] Scheduled job not found for type: ${job.id}`);
setJobs(prev => prev.map((j, idx) =>
idx === i ? { ...j, status: 'error', error: 'Job configuration not found' } : j
));
continue;
}
try {
// Look for the most recent job execution
// Jobs should already be triggered by OIDCAuthProvider
const response = await fetch('/api/admin/jobs', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Error('Failed to fetch job status');
}
const data = await response.json();
const jobsData = data.jobs || [];
// Find the most recent job of this type
const recentJob = jobsData.find((j: any) => j.type === job.id);
if (!recentJob || !recentJob.lastRunJobId) {
console.warn(`[Initializing] No recent job found for ${job.name}, may still be starting`);
// Wait a bit and check again
await new Promise(resolve => setTimeout(resolve, 3000));
const retryResponse = await fetch('/api/admin/jobs', {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (retryResponse.ok) {
const retryData = await retryResponse.json();
const retryJobData = retryData.jobs?.find((j: any) => j.type === job.id);
if (retryJobData?.lastRunJobId) {
const finalStatus = await pollJobStatus(retryJobData.lastRunJobId, accessToken);
setJobs(prev => prev.map((j, idx) =>
idx === i ? {
...j,
status: finalStatus === 'completed' ? 'completed' : 'error',
error: finalStatus === 'failed' ? 'Job failed to complete' : undefined,
} : j
));
} else {
// Give up, mark as error
setJobs(prev => prev.map((j, idx) =>
idx === i ? { ...j, status: 'error', error: 'Job did not start' } : j
));
}
}
} else {
// Poll the existing job
const finalStatus = await pollJobStatus(recentJob.lastRunJobId, accessToken);
setJobs(prev => prev.map((j, idx) =>
idx === i ? {
...j,
status: finalStatus === 'completed' ? 'completed' : 'error',
error: finalStatus === 'failed' ? 'Job failed to complete' : undefined,
} : j
));
}
// Small delay between jobs
if (i < jobs.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
} catch (error) {
console.error(`[Initializing] Failed to monitor job ${job.name}:`, error);
setJobs(prev => prev.map((j, idx) =>
idx === i ? {
...j,
status: 'error',
error: error instanceof Error ? error.message : 'Failed to monitor job'
} : j
));
}
}
// All jobs complete
setIsComplete(true);
};
const allJobsCompleted = jobs.every(job => job.status === 'completed' || job.status === 'error');
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto">
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
Welcome to ReadMeABook!
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Setting up your audiobook library...
</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-6">
Initial Setup Progress
</h2>
<div className="space-y-4">
{jobs.map((job, index) => (
<div
key={job.id}
className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-2 border-gray-200 dark:border-gray-700"
>
<div className="flex items-start gap-4">
{/* Status Icon */}
<div className="flex-shrink-0 mt-1">
{job.status === 'pending' && (
<div className="w-6 h-6 rounded-full border-2 border-gray-300 dark:border-gray-600" />
)}
{job.status === 'running' && (
<div className="w-6 h-6 rounded-full border-2 border-blue-500 border-t-transparent animate-spin" />
)}
{job.status === 'completed' && (
<svg
className="w-6 h-6 text-green-600 dark:text-green-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
)}
{job.status === 'error' && (
<svg
className="w-6 h-6 text-red-600 dark:text-red-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
)}
</div>
{/* Job Info */}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{job.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
{job.description}
</p>
{job.status === 'running' && (
<p className="text-sm text-blue-600 dark:text-blue-400 mt-2 font-medium">
Running...
</p>
)}
{job.status === 'completed' && (
<p className="text-sm text-green-600 dark:text-green-400 mt-2 font-medium">
Completed successfully
</p>
)}
{job.status === 'error' && (
<p className="text-sm text-red-600 dark:text-red-400 mt-2 font-medium">
Error: {job.error}
</p>
)}
</div>
</div>
</div>
))}
</div>
{isComplete && (
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Initial setup complete!
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
Your library is ready. These jobs will run automatically on a schedule to keep your catalog fresh.
</p>
</div>
</div>
</div>
)}
<div className="mt-6">
<Button
onClick={() => router.push('/')}
disabled={!allJobsCompleted}
size="lg"
className="w-full"
>
{allJobsCompleted ? 'Go to Homepage' : 'Please wait...'}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
+545
View File
@@ -0,0 +1,545 @@
/**
* Component: Setup Wizard Page
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { WizardLayout } from './components/WizardLayout';
import { WelcomeStep } from './steps/WelcomeStep';
import { BackendSelectionStep } from './steps/BackendSelectionStep';
import { AdminAccountStep } from './steps/AdminAccountStep';
import { PlexStep } from './steps/PlexStep';
import { AudiobookshelfStep } from './steps/AudiobookshelfStep';
import { AuthMethodStep } from './steps/AuthMethodStep';
import { OIDCConfigStep } from './steps/OIDCConfigStep';
import { RegistrationSettingsStep } from './steps/RegistrationSettingsStep';
import { ProwlarrStep } from './steps/ProwlarrStep';
import { DownloadClientStep } from './steps/DownloadClientStep';
import { PathsStep } from './steps/PathsStep';
import { BookDateStep } from './steps/BookDateStep';
import { ReviewStep } from './steps/ReviewStep';
import { FinalizeStep } from './steps/FinalizeStep';
interface SelectedIndexer {
id: number;
name: string;
priority: number;
}
interface SetupState {
currentStep: number;
// Backend selection
backendMode: 'plex' | 'audiobookshelf';
// Admin account (for Plex mode and ABS + Manual mode)
adminUsername: string;
adminPassword: string;
// Plex config (if mode=plex)
plexUrl: string;
plexToken: string;
plexLibraryId: string;
// Audiobookshelf config (if mode=audiobookshelf)
absUrl: string;
absApiToken: string;
absLibraryId: string;
// Auth config (if mode=audiobookshelf)
authMethod: 'oidc' | 'manual' | 'both';
// OIDC config
oidcProviderName: string;
oidcIssuerUrl: string;
oidcClientId: string;
oidcClientSecret: string;
// Manual registration config
requireAdminApproval: boolean;
// Prowlarr, download client, paths, bookdate (common to both modes)
prowlarrUrl: string;
prowlarrApiKey: string;
prowlarrIndexers: SelectedIndexer[];
downloadClient: 'qbittorrent' | 'transmission';
downloadClientUrl: string;
downloadClientUsername: string;
downloadClientPassword: string;
downloadDir: string;
mediaDir: string;
metadataTaggingEnabled: boolean;
bookdateProvider: string;
bookdateApiKey: string;
bookdateModel: string;
bookdateConfigured: boolean;
validated: {
plex: boolean;
prowlarr: boolean;
downloadClient: boolean;
paths: boolean;
};
}
export default function SetupWizard() {
const router = useRouter();
const [state, setState] = useState<SetupState>({
currentStep: 1,
// Backend selection
backendMode: 'plex',
// Admin account
adminUsername: 'admin',
adminPassword: '',
// Plex config
plexUrl: '',
plexToken: '',
plexLibraryId: '',
// Audiobookshelf config
absUrl: '',
absApiToken: '',
absLibraryId: '',
// Auth config
authMethod: 'oidc',
// OIDC config
oidcProviderName: 'Authentik',
oidcIssuerUrl: '',
oidcClientId: '',
oidcClientSecret: '',
// Manual registration config
requireAdminApproval: true,
// Common config
prowlarrUrl: '',
prowlarrApiKey: '',
prowlarrIndexers: [],
downloadClient: 'qbittorrent',
downloadClientUrl: '',
downloadClientUsername: 'admin',
downloadClientPassword: '',
downloadDir: '/downloads',
mediaDir: '/media/audiobooks',
metadataTaggingEnabled: true,
bookdateProvider: 'openai',
bookdateApiKey: '',
bookdateModel: '',
bookdateConfigured: false,
validated: {
plex: false,
prowlarr: false,
downloadClient: false,
paths: false,
},
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [setupHasAdminTokens, setSetupHasAdminTokens] = useState(false);
// Calculate total steps based on backend mode and auth method
const getTotalSteps = () => {
if (state.backendMode === 'plex') {
// Plex mode: Welcome, Backend, Admin, Plex, Prowlarr, Download, Paths, BookDate, Review, Finalize
return 10;
} else {
// ABS mode: base steps + conditional auth steps
let steps = 10; // Welcome, Backend, ABS, Auth Method, Prowlarr, Download, Paths, BookDate, Review, Finalize
if (state.authMethod === 'oidc' || state.authMethod === 'both') {
steps += 1; // OIDC Config
}
if (state.authMethod === 'manual' || state.authMethod === 'both') {
steps += 2; // Registration Settings + Admin Account
}
return steps;
}
};
const totalSteps = getTotalSteps();
const updateState = (updates: Partial<SetupState>) => {
setState((prev) => ({ ...prev, ...updates }));
};
const updateField = (field: string, value: any) => {
setState((prev) => ({ ...prev, [field]: value }));
};
const goToStep = (step: number) => {
setState((prev) => ({ ...prev, currentStep: step }));
setError(null);
};
const completeSetup = async () => {
setLoading(true);
setError(null);
try {
const payload: any = {
backendMode: state.backendMode,
prowlarr: {
url: state.prowlarrUrl,
api_key: state.prowlarrApiKey,
indexers: state.prowlarrIndexers,
},
downloadClient: {
type: state.downloadClient,
url: state.downloadClientUrl,
username: state.downloadClientUsername,
password: state.downloadClientPassword,
},
paths: {
download_dir: state.downloadDir,
media_dir: state.mediaDir,
metadata_tagging_enabled: state.metadataTaggingEnabled,
},
bookdate: state.bookdateConfigured ? {
provider: state.bookdateProvider,
apiKey: state.bookdateApiKey,
model: state.bookdateModel,
} : null,
};
if (state.backendMode === 'plex') {
// Plex mode configuration
payload.admin = {
username: state.adminUsername,
password: state.adminPassword,
};
payload.plex = {
url: state.plexUrl,
token: state.plexToken,
audiobook_library_id: state.plexLibraryId,
};
} else {
// Audiobookshelf mode configuration
payload.audiobookshelf = {
server_url: state.absUrl,
api_token: state.absApiToken,
library_id: state.absLibraryId,
};
payload.authMethod = state.authMethod;
// OIDC configuration
if (state.authMethod === 'oidc' || state.authMethod === 'both') {
payload.oidc = {
provider_name: state.oidcProviderName,
issuer_url: state.oidcIssuerUrl,
client_id: state.oidcClientId,
client_secret: state.oidcClientSecret,
};
}
// Manual registration configuration
if (state.authMethod === 'manual' || state.authMethod === 'both') {
payload.registration = {
enabled: true,
require_admin_approval: state.requireAdminApproval,
};
// Create admin account for manual auth
payload.admin = {
username: state.adminUsername,
password: state.adminPassword,
};
}
}
const response = await fetch('/api/setup/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to complete setup');
}
const data = await response.json();
// Store admin auth tokens (if provided)
if (data.accessToken && data.refreshToken) {
// Clear any old tokens first to avoid conflicts
localStorage.clear();
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
localStorage.setItem('user', JSON.stringify(data.user));
// Mark that we have admin tokens for FinalizeStep
setSetupHasAdminTokens(true);
// Go to finalize step to run initial jobs
goToStep(totalSteps);
} else {
// OIDC-only mode - clear localStorage to remove stale tokens
localStorage.clear();
// Mark that we don't have admin tokens
setSetupHasAdminTokens(false);
// Go to finalize step (will show OIDC-only UI)
goToStep(totalSteps);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Setup failed');
} finally {
setLoading(false);
}
};
const renderStep = () => {
let currentStepNumber = 1;
// Step 1: Welcome
if (state.currentStep === currentStepNumber) {
return <WelcomeStep onNext={() => goToStep(currentStepNumber + 1)} />;
}
currentStepNumber++;
// Step 2: Backend Selection
if (state.currentStep === currentStepNumber) {
return (
<BackendSelectionStep
value={state.backendMode}
onChange={(value) => updateField('backendMode', value)}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Conditional flow based on backend mode
if (state.backendMode === 'plex') {
// Plex Mode: Admin → Plex → Prowlarr → Download → Paths → BookDate → Review → Finalize
// Step 3: Admin Account
if (state.currentStep === currentStepNumber) {
return (
<AdminAccountStep
adminUsername={state.adminUsername}
adminPassword={state.adminPassword}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step 4: Plex
if (state.currentStep === currentStepNumber) {
return (
<PlexStep
plexUrl={state.plexUrl}
plexToken={state.plexToken}
plexLibraryId={state.plexLibraryId}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
} else {
// Audiobookshelf Mode: ABS → Auth Method → [OIDC Config] → [Registration Settings] → [Admin Account] → Prowlarr → ...
// Step 3: Audiobookshelf
if (state.currentStep === currentStepNumber) {
return (
<AudiobookshelfStep
absUrl={state.absUrl}
absApiToken={state.absApiToken}
absLibraryId={state.absLibraryId}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step 4: Auth Method Selection
if (state.currentStep === currentStepNumber) {
return (
<AuthMethodStep
value={state.authMethod}
onChange={(value) => updateField('authMethod', value)}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Conditional: OIDC Config (if authMethod is 'oidc' or 'both')
if (state.authMethod === 'oidc' || state.authMethod === 'both') {
if (state.currentStep === currentStepNumber) {
return (
<OIDCConfigStep
oidcProviderName={state.oidcProviderName}
oidcIssuerUrl={state.oidcIssuerUrl}
oidcClientId={state.oidcClientId}
oidcClientSecret={state.oidcClientSecret}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
}
// Conditional: Registration Settings (if authMethod is 'manual' or 'both')
if (state.authMethod === 'manual' || state.authMethod === 'both') {
if (state.currentStep === currentStepNumber) {
return (
<RegistrationSettingsStep
requireAdminApproval={state.requireAdminApproval}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: Admin Account (for manual auth)
if (state.currentStep === currentStepNumber) {
return (
<AdminAccountStep
adminUsername={state.adminUsername}
adminPassword={state.adminPassword}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
}
}
// Common steps for both modes: Prowlarr → Download → Paths → BookDate → Review → Finalize
// Step: Prowlarr
if (state.currentStep === currentStepNumber) {
return (
<ProwlarrStep
prowlarrUrl={state.prowlarrUrl}
prowlarrApiKey={state.prowlarrApiKey}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: Download Client
if (state.currentStep === currentStepNumber) {
return (
<DownloadClientStep
downloadClient={state.downloadClient}
downloadClientUrl={state.downloadClientUrl}
downloadClientUsername={state.downloadClientUsername}
downloadClientPassword={state.downloadClientPassword}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: Paths
if (state.currentStep === currentStepNumber) {
return (
<PathsStep
downloadDir={state.downloadDir}
mediaDir={state.mediaDir}
metadataTaggingEnabled={state.metadataTaggingEnabled}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: BookDate
if (state.currentStep === currentStepNumber) {
return (
<BookDateStep
bookdateProvider={state.bookdateProvider}
bookdateApiKey={state.bookdateApiKey}
bookdateModel={state.bookdateModel}
bookdateConfigured={state.bookdateConfigured}
onUpdate={updateField}
onNext={() => goToStep(currentStepNumber + 1)}
onSkip={() => goToStep(currentStepNumber + 1)}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: Review
if (state.currentStep === currentStepNumber) {
return (
<ReviewStep
config={state}
loading={loading}
error={error}
onComplete={completeSetup}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
currentStepNumber++;
// Step: Finalize
if (state.currentStep === currentStepNumber) {
return (
<FinalizeStep
hasAdminTokens={setupHasAdminTokens}
onComplete={() => {
// OIDC-only mode: redirect to login
if (!setupHasAdminTokens) {
window.location.href = '/login';
return;
}
// Normal mode: Force full page reload to initialize auth context with new tokens
window.location.href = '/';
}}
onBack={() => goToStep(currentStepNumber - 1)}
/>
);
}
return null;
};
return (
<WizardLayout
currentStep={state.currentStep}
totalSteps={totalSteps}
backendMode={state.backendMode}
authMethod={state.authMethod}
>
{renderStep()}
</WizardLayout>
);
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Component: Admin Account Setup Step
* Documentation: documentation/setup-wizard.md
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
interface AdminAccountStepProps {
adminUsername: string;
adminPassword: string;
onUpdate: (field: string, value: string) => void;
onNext: () => void;
onBack: () => void;
}
export function AdminAccountStep({
adminUsername,
adminPassword,
onUpdate,
onNext,
onBack,
}: AdminAccountStepProps) {
const [confirmPassword, setConfirmPassword] = useState('');
const [errors, setErrors] = useState<{ username?: string; password?: string; confirm?: string }>({});
const validate = () => {
const newErrors: { username?: string; password?: string; confirm?: string } = {};
// Validate username
if (!adminUsername || adminUsername.length < 3) {
newErrors.username = 'Username must be at least 3 characters';
}
// Validate password
if (!adminPassword || adminPassword.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
// Validate password confirmation
if (adminPassword !== confirmPassword) {
newErrors.confirm = 'Passwords do not match';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleNext = () => {
if (validate()) {
onNext();
}
};
return (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-3xl font-bold text-white mb-2">Create Admin Account</h2>
<p className="text-gray-400">
Set up your administrator account to manage the application
</p>
</div>
<div className="space-y-4">
{/* Username */}
<div>
<label htmlFor="adminUsername" className="block text-sm font-medium text-gray-300 mb-2">
Username
</label>
<input
type="text"
id="adminUsername"
value={adminUsername}
onChange={(e) => onUpdate('adminUsername', e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="admin"
autoComplete="username"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-400">{errors.username}</p>
)}
<p className="mt-1 text-xs text-gray-500">
This will be your local admin username (minimum 3 characters)
</p>
</div>
{/* Password */}
<div>
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-300 mb-2">
Password
</label>
<input
type="password"
id="adminPassword"
value={adminPassword}
onChange={(e) => onUpdate('adminPassword', e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••"
autoComplete="new-password"
/>
{errors.password && (
<p className="mt-1 text-sm text-red-400">{errors.password}</p>
)}
<p className="mt-1 text-xs text-gray-500">
Choose a strong password (minimum 8 characters)
</p>
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-300 mb-2">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="••••••••"
autoComplete="new-password"
/>
{errors.confirm && (
<p className="mt-1 text-sm text-red-400">{errors.confirm}</p>
)}
</div>
{/* Info Box */}
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
<svg className="w-5 h-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="text-sm text-blue-300">
<p className="font-medium mb-1">About Admin Accounts</p>
<p className="text-blue-400">
This local admin account is separate from media server authentication. Use it to access
admin settings and manage the application.
</p>
</div>
</div>
</div>
</div>
{/* Navigation */}
<div className="flex gap-3 pt-4">
<Button
onClick={onBack}
variant="outline"
className="flex-1"
>
Back
</Button>
<Button
onClick={handleNext}
className="flex-1 bg-orange-600 hover:bg-orange-700"
>
Next
</Button>
</div>
</div>
);
}
+264
View File
@@ -0,0 +1,264 @@
/**
* Component: Audiobookshelf Configuration Step
* Documentation: documentation/features/audiobookshelf-integration.md
*/
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface AudiobookshelfStepProps {
absUrl: string;
absApiToken: string;
absLibraryId: string;
onUpdate: (field: string, value: string) => void;
onNext: () => void;
onBack: () => void;
}
interface Library {
id: string;
name: string;
itemCount: number;
}
export function AudiobookshelfStep({
absUrl,
absApiToken,
absLibraryId,
onUpdate,
onNext,
onBack,
}: AudiobookshelfStepProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message?: string;
libraries?: Library[];
} | null>(null);
const [libraries, setLibraries] = useState<Library[]>([]);
const testConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const response = await fetch('/api/setup/test-abs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serverUrl: absUrl, apiToken: absApiToken }),
});
const data = await response.json();
if (response.ok && data.success) {
setTestResult({
success: true,
message: 'Connection successful!',
libraries: data.libraries || [],
});
setLibraries(data.libraries || []);
} else {
setTestResult({
success: false,
message: data.error || 'Connection failed',
});
}
} catch (error) {
setTestResult({
success: false,
message: error instanceof Error ? error.message : 'Connection test failed',
});
} finally {
setTesting(false);
}
};
const handleNext = () => {
if (!testResult?.success) {
setTestResult({
success: false,
message: 'Please test the connection before proceeding',
});
return;
}
if (!absLibraryId) {
setTestResult({
success: false,
message: 'Please select an audiobook library',
});
return;
}
onNext();
};
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Configure Audiobookshelf
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Enter your Audiobookshelf server details and API token.
</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Server URL
</label>
<Input
type="url"
placeholder="http://audiobookshelf:13378"
value={absUrl}
onChange={(e) => onUpdate('absUrl', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
The URL where your Audiobookshelf server is running (include port)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
API Token
</label>
<Input
type="password"
placeholder="Your API token"
value={absApiToken}
onChange={(e) => onUpdate('absApiToken', e.target.value)}
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Find this in Audiobookshelf Settings Users Your User API Token
</p>
</div>
<Button
onClick={testConnection}
loading={testing}
disabled={!absUrl || !absApiToken}
variant="outline"
className="w-full"
>
Test Connection
</Button>
{testResult && (
<div
className={`rounded-lg p-4 ${
testResult.success
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800'
}`}
>
<div className="flex gap-3">
<svg
className={`w-6 h-6 flex-shrink-0 ${
testResult.success
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
fill="currentColor"
viewBox="0 0 20 20"
>
{testResult.success ? (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
)}
</svg>
<div>
<h3
className={`text-sm font-medium ${
testResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}
>
{testResult.success ? 'Success' : 'Error'}
</h3>
<p
className={`text-sm mt-1 ${
testResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}
>
{testResult.message}
</p>
</div>
</div>
</div>
)}
{libraries.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Audiobook Library
</label>
<select
value={absLibraryId}
onChange={(e) => onUpdate('absLibraryId', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">Select a library...</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
{lib.name} ({lib.itemCount} items)
</option>
))}
</select>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Select the library containing your audiobooks
</p>
</div>
)}
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
About API Token
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
You can generate an API token in Audiobookshelf by going to Settings Users
selecting your user and copying the API Token.
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={handleNext}>Next</Button>
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
/**
* Component: Authentication Method Selection Step
* Documentation: documentation/features/audiobookshelf-integration.md
*/
'use client';
import { Button } from '@/components/ui/Button';
interface AuthMethodStepProps {
value: 'oidc' | 'manual' | 'both';
onChange: (value: 'oidc' | 'manual' | 'both') => void;
onNext: () => void;
onBack: () => void;
}
export function AuthMethodStep({
value,
onChange,
onNext,
onBack,
}: AuthMethodStepProps) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Choose Authentication Method
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Select how users will authenticate to access ReadMeABook.
</p>
</div>
<div className="space-y-4">
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition ${
value === 'oidc'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="authMethod"
value="oidc"
checked={value === 'oidc'}
onChange={() => onChange('oidc')}
className="sr-only"
/>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
OIDC Provider
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Use Authentik, Keycloak, or other OIDC-compatible identity provider for
single sign-on.
</p>
</div>
</label>
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition ${
value === 'manual'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="authMethod"
value="manual"
checked={value === 'manual'}
onChange={() => onChange('manual')}
className="sr-only"
/>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
Manual Registration
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Users create accounts with username and password. Optional admin approval.
</p>
</div>
</label>
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition ${
value === 'both'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="authMethod"
value="both"
checked={value === 'both'}
onChange={() => onChange('both')}
className="sr-only"
/>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">Both</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Enable OIDC as primary authentication with password-based registration as a
fallback option.
</p>
</div>
</label>
</div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Recommendation
</p>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
OIDC is recommended for better security and centralized user management. Choose
"Both" if you want to provide a fallback option for users without OIDC access.
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={onNext}>Next</Button>
</div>
</div>
);
}
@@ -0,0 +1,130 @@
/**
* Component: Backend Selection Step
* Documentation: documentation/features/audiobookshelf-integration.md
*/
'use client';
import { Button } from '@/components/ui/Button';
interface BackendSelectionStepProps {
value: 'plex' | 'audiobookshelf';
onChange: (value: 'plex' | 'audiobookshelf') => void;
onNext: () => void;
onBack: () => void;
}
export function BackendSelectionStep({
value,
onChange,
onNext,
onBack,
}: BackendSelectionStepProps) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Choose Your Library Backend
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Select which media server you'll use to manage your audiobook library.
</p>
</div>
<div className="space-y-4">
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition ${
value === 'plex'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="backend"
value="plex"
checked={value === 'plex'}
onChange={() => onChange('plex')}
className="sr-only"
/>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-orange-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white text-2xl font-bold">P</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
Plex Media Server
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Use Plex for library management. Authentication via Plex OAuth.
</p>
</div>
</div>
</label>
<label
className={`block p-4 border-2 rounded-lg cursor-pointer transition ${
value === 'audiobookshelf'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<input
type="radio"
name="backend"
value="audiobookshelf"
checked={value === 'audiobookshelf'}
onChange={() => onChange('audiobookshelf')}
className="sr-only"
/>
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-500 rounded-lg flex items-center justify-center flex-shrink-0">
<span className="text-white text-2xl font-bold">A</span>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
Audiobookshelf
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400">
Use Audiobookshelf for library management. Choose OIDC or password
authentication.
</p>
</div>
</div>
</label>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex gap-3">
<svg
className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<div>
<p className="text-sm font-medium text-yellow-900 dark:text-yellow-100">
Important Note
</p>
<p className="text-sm text-yellow-800 dark:text-yellow-200 mt-1">
This choice cannot be changed after setup. To switch backends, you'll need
to reset the application.
</p>
</div>
</div>
</div>
<div className="flex justify-between pt-4">
<Button onClick={onBack} variant="outline">
Back
</Button>
<Button onClick={onNext}>Next</Button>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More