mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "{confirmDialog.jobName}" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user