Initial commit

This commit is contained in:
kikootwo
2026-01-28 11:41:24 -05:00
commit a3ba192fbd
257 changed files with 89482 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
/**
* Backend Mode API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { ConfigurationService } from '@/lib/services/config.service';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
try {
const configService = new ConfigurationService();
const backendMode = await configService.getBackendMode();
return NextResponse.json({
backendMode,
isAudiobookshelf: backendMode === 'audiobookshelf'
});
} catch (error) {
console.error('[BackendMode] Failed to get backend mode:', error);
return NextResponse.json(
{ error: 'Failed to get backend mode' },
{ status: 500 }
);
}
});
}
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { mode } = body;
if (!mode || (mode !== 'plex' && mode !== 'audiobookshelf')) {
return NextResponse.json(
{ error: 'Invalid backend mode. Must be "plex" or "audiobookshelf"' },
{ status: 400 }
);
}
const configService = new ConfigurationService();
await configService.setMany([
{ key: 'system.backend_mode', value: mode, category: 'system' }
]);
// Clear library service cache to force re-initialization with new mode
const { clearLibraryServiceCache } = await import('@/lib/services/library');
clearLibraryServiceCache();
console.log(`[BackendMode] Backend mode changed to: ${mode}`);
return NextResponse.json({
success: true,
backendMode: mode,
message: `Backend mode set to ${mode}`
});
} catch (error) {
console.error('[BackendMode] Failed to set backend mode:', error);
return NextResponse.json(
{ error: 'Failed to set backend mode' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,44 @@
/**
* BookDate: Admin Global Toggle
* Documentation: documentation/features/bookdate-prd.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
async function handler(req: AuthenticatedRequest) {
try {
const body = await req.json();
const { isEnabled } = body;
if (typeof isEnabled !== 'boolean') {
return NextResponse.json(
{ error: 'isEnabled must be a boolean' },
{ status: 400 }
);
}
// Update all BookDate configurations
await prisma.bookDateConfig.updateMany({
data: { isEnabled },
});
return NextResponse.json({
success: true,
isEnabled,
message: `BookDate ${isEnabled ? 'enabled' : 'disabled'} for all users`,
});
} catch (error: any) {
console.error('[BookDate] Admin toggle error:', error);
return NextResponse.json(
{ error: error.message || 'Failed to toggle BookDate' },
{ status: 500 }
);
}
}
export async function PATCH(req: NextRequest) {
return requireAuth(req, (authReq) => requireAdmin(authReq, handler));
}
@@ -0,0 +1,76 @@
/**
* Component: Admin Active Downloads API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Get active downloads with related data
const activeDownloads = await prisma.request.findMany({
where: {
status: 'downloading',
},
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
},
},
user: {
select: {
id: true,
plexUsername: true,
},
},
downloadHistory: {
where: {
downloadStatus: 'downloading',
},
orderBy: {
createdAt: 'desc',
},
take: 1,
select: {
downloadStatus: true,
torrentName: true,
},
},
},
orderBy: {
updatedAt: 'desc',
},
take: 20,
});
// Format response
const formatted = activeDownloads.map((download) => ({
requestId: download.id,
title: download.audiobook.title,
author: download.audiobook.author,
status: download.status,
progress: download.progress,
torrentName: download.downloadHistory[0]?.torrentName || null,
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
user: download.user.plexUsername,
startedAt: download.updatedAt,
}));
return NextResponse.json({ downloads: formatted });
} catch (error) {
console.error('[Admin] Failed to fetch active downloads:', error);
return NextResponse.json(
{ error: 'Failed to fetch active downloads' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,70 @@
/**
* Component: Admin Job Execution Status API
* Documentation: documentation/backend/services/jobs.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getJobQueueService } from '@/lib/services/job-queue.service';
/**
* GET /api/admin/job-status/:id
* Get job execution status by database job ID
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
console.log(`[JobStatus] Fetching status for job ID: ${id}`);
const jobQueueService = getJobQueueService();
const job = await jobQueueService.getJob(id);
if (!job) {
console.log(`[JobStatus] Job not found: ${id}`);
return NextResponse.json({ error: 'Job not found' }, { status: 404 });
}
console.log(`[JobStatus] Job ${id} status: ${job.status}, type: ${job.type}`);
return NextResponse.json({
success: true,
job: {
id: job.id,
type: job.type,
status: job.status,
createdAt: job.createdAt,
startedAt: job.startedAt,
completedAt: job.completedAt,
result: job.result,
errorMessage: job.errorMessage,
attempts: job.attempts,
maxAttempts: job.maxAttempts,
},
});
} catch (error) {
console.error('Failed to get job status:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to get job status',
},
{ status: 500 }
);
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Component: Admin Job Update API
* Documentation: documentation/backend/services/scheduler.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getSchedulerService } from '@/lib/services/scheduler.service';
/**
* PUT /api/admin/jobs/:id
* Update a scheduled job
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
const body = await request.json();
const schedulerService = getSchedulerService();
const job = await schedulerService.updateScheduledJob(id, {
name: body.name,
schedule: body.schedule,
enabled: body.enabled,
payload: body.payload,
});
return NextResponse.json({
success: true,
job,
});
} catch (error) {
console.error('Failed to update scheduled job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to update scheduled job',
},
{ status: 500 }
);
}
}
/**
* DELETE /api/admin/jobs/:id
* Delete a scheduled job
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
const schedulerService = getSchedulerService();
await schedulerService.deleteScheduledJob(id);
return NextResponse.json({
success: true,
message: 'Job deleted successfully',
});
} catch (error) {
console.error('Failed to delete scheduled job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to delete scheduled job',
},
{ status: 500 }
);
}
}
@@ -0,0 +1,55 @@
/**
* Component: Admin Job Trigger API
* Documentation: documentation/backend/services/scheduler.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getSchedulerService } from '@/lib/services/scheduler.service';
/**
* POST /api/admin/jobs/:id/trigger
* Manually trigger a scheduled job
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
// Await params in Next.js 15+
const { id } = await params;
console.log(`[JobTrigger] Triggering scheduled job: ${id}`);
const schedulerService = getSchedulerService();
const jobId = await schedulerService.triggerJobNow(id);
console.log(`[JobTrigger] Job triggered successfully, database job ID: ${jobId}`);
return NextResponse.json({
success: true,
jobId,
message: 'Job triggered successfully',
});
} catch (error) {
console.error('Failed to trigger job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to trigger job',
},
{ status: 500 }
);
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Component: Admin Jobs Management API
* Documentation: documentation/backend/services/scheduler.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { verifyAccessToken } from '@/lib/utils/jwt';
import { getSchedulerService } from '@/lib/services/scheduler.service';
/**
* GET /api/admin/jobs
* Get all scheduled jobs
*/
export async function GET(request: NextRequest) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
const schedulerService = getSchedulerService();
const jobs = await schedulerService.getScheduledJobs();
return NextResponse.json({
jobs,
});
} catch (error) {
console.error('Failed to get scheduled jobs:', error);
return NextResponse.json(
{
error: 'InternalError',
message: 'Failed to retrieve scheduled jobs',
},
{ status: 500 }
);
}
}
/**
* POST /api/admin/jobs
* Create a new scheduled job
*/
export async function POST(request: NextRequest) {
try {
// Verify admin auth
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = verifyAccessToken(token);
if (!payload || payload.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 });
}
const body = await request.json();
const schedulerService = getSchedulerService();
const job = await schedulerService.createScheduledJob({
name: body.name,
type: body.type,
schedule: body.schedule,
enabled: body.enabled,
payload: body.payload,
});
return NextResponse.json({
job,
});
} catch (error) {
console.error('Failed to create scheduled job:', error);
return NextResponse.json(
{
error: 'InternalError',
message: error instanceof Error ? error.message : 'Failed to create scheduled job',
},
{ status: 500 }
);
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Component: Admin Logs API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '100');
const status = searchParams.get('status') || 'all';
const type = searchParams.get('type') || 'all';
const skip = (page - 1) * limit;
// Build where clause
const where: any = {};
if (status !== 'all') {
where.status = status;
}
if (type !== 'all') {
where.type = type;
}
const [logs, totalCount] = await Promise.all([
prisma.job.findMany({
where,
select: {
id: true,
bullJobId: true,
type: true,
status: true,
priority: true,
attempts: true,
maxAttempts: true,
errorMessage: true,
startedAt: true,
completedAt: true,
createdAt: true,
updatedAt: true,
result: true,
events: {
select: {
id: true,
level: true,
context: true,
message: true,
metadata: true,
createdAt: true,
},
orderBy: {
createdAt: 'asc',
},
},
request: {
select: {
id: true,
audiobook: {
select: {
title: true,
author: true,
},
},
user: {
select: {
plexUsername: true,
},
},
},
},
},
orderBy: {
createdAt: 'desc',
},
skip,
take: limit,
}),
prisma.job.count({ where }),
]);
return NextResponse.json({
logs,
pagination: {
page,
limit,
total: totalCount,
totalPages: Math.ceil(totalCount / limit),
},
});
} catch (error) {
console.error('[Admin] Failed to fetch logs:', error);
return NextResponse.json(
{ error: 'Failed to fetch logs' },
{ status: 500 }
);
}
});
});
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Component: Admin Metrics API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Get system metrics
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const [
totalRequests,
activeDownloads,
completedLast30Days,
failedLast30Days,
totalUsers,
] = await Promise.all([
// Total requests (all time)
prisma.request.count(),
// Active downloads (downloading status)
prisma.request.count({
where: {
status: 'downloading',
},
}),
// Completed requests (last 30 days) - 'downloaded' and 'available' statuses
prisma.request.count({
where: {
status: {
in: ['downloaded', 'available'],
},
completedAt: {
gte: thirtyDaysAgo,
},
},
}),
// Failed requests (last 30 days)
prisma.request.count({
where: {
status: 'failed',
updatedAt: {
gte: thirtyDaysAgo,
},
},
}),
// Total users
prisma.user.count(),
]);
// Check system health
const systemHealth = await checkSystemHealth();
return NextResponse.json({
totalRequests,
activeDownloads,
completedLast30Days,
failedLast30Days,
totalUsers,
systemHealth,
});
} catch (error) {
console.error('[Admin] Failed to fetch metrics:', error);
return NextResponse.json(
{ error: 'Failed to fetch metrics' },
{ status: 500 }
);
}
});
});
}
async function checkSystemHealth(): Promise<{
status: 'healthy' | 'degraded' | 'unhealthy';
issues: string[];
}> {
const issues: string[] = [];
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`;
} catch (error) {
issues.push('Database connection failed');
}
// Check for stale downloads (downloading for more than 24 hours)
const oneDayAgo = new Date();
oneDayAgo.setHours(oneDayAgo.getHours() - 24);
const staleDownloads = await prisma.request.count({
where: {
status: 'downloading',
updatedAt: {
lt: oneDayAgo,
},
},
});
if (staleDownloads > 0) {
issues.push(`${staleDownloads} stale downloads (>24h)`);
}
// Determine overall status
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
if (issues.length > 0) {
status = issues.some((i) => i.includes('Database')) ? 'unhealthy' : 'degraded';
}
return { status, issues };
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Component: Admin Plex Library Scan API
* Documentation: documentation/integrations/plex.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin } from '@/lib/middleware/auth';
import { processScanPlex } from '@/lib/processors/scan-plex.processor';
/**
* POST /api/admin/plex/scan
* Trigger a Plex library scan to update availability status for audiobooks
* Admin-only endpoint
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req) => {
return requireAdmin(req, async () => {
try {
// Trigger scan with empty payload (will use configured library ID)
const result = await processScanPlex({
libraryId: undefined,
partial: false,
});
return NextResponse.json({
success: true,
...result,
});
} catch (error) {
console.error('[API] Plex scan failed:', error);
return NextResponse.json(
{
error: 'ScanFailed',
message: error instanceof Error ? error.message : 'Failed to scan Plex library',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,59 @@
/**
* Component: Admin Recent Requests API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Get recent requests
const recentRequests = await prisma.request.findMany({
include: {
audiobook: {
select: {
id: true,
title: true,
author: true,
},
},
user: {
select: {
id: true,
plexUsername: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
take: 50,
});
// Format response
const formatted = recentRequests.map((request) => ({
requestId: request.id,
title: request.audiobook.title,
author: request.audiobook.author,
status: request.status,
user: request.user.plexUsername,
createdAt: request.createdAt,
completedAt: request.completedAt,
errorMessage: request.errorMessage,
}));
return NextResponse.json({ requests: formatted });
} catch (error) {
console.error('[Admin] Failed to fetch recent requests:', error);
return NextResponse.json(
{ error: 'Failed to fetch recent requests' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,66 @@
/**
* Audiobookshelf Libraries API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
export async function GET(request: NextRequest) {
console.log('[ABS Libraries] GET request received');
return requireAuth(request, async (req: AuthenticatedRequest) => {
console.log('[ABS Libraries] Auth passed, user:', req.user);
return requireAdmin(req, async () => {
console.log('[ABS Libraries] Admin check passed');
try {
// Use getConfigService like Plex endpoint does
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const serverUrl = await configService.get('audiobookshelf.server_url');
const apiToken = await configService.get('audiobookshelf.api_token');
console.log('[ABS Libraries] Config loaded:', { hasServerUrl: !!serverUrl, hasApiToken: !!apiToken });
if (!serverUrl || !apiToken) {
return NextResponse.json(
{ error: 'Audiobookshelf not configured' },
{ status: 400 }
);
}
// Fetch libraries from Audiobookshelf
const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/libraries`, {
headers: {
'Authorization': `Bearer ${apiToken}`,
},
});
if (!response.ok) {
return NextResponse.json(
{ error: 'Failed to fetch libraries from Audiobookshelf' },
{ status: response.status }
);
}
const data = await response.json();
// Filter to only audiobook libraries and map to expected format
const libraries = (data.libraries || [])
.filter((lib: any) => lib.mediaType === 'book')
.map((lib: any) => ({
id: lib.id,
name: lib.name,
type: lib.mediaType,
itemCount: lib.stats?.totalItems || 0,
}));
return NextResponse.json({ libraries });
} catch (error) {
console.error('[Admin] Failed to fetch ABS libraries:', error);
return NextResponse.json(
{ error: 'Failed to fetch libraries' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,51 @@
/**
* Audiobookshelf Settings API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { ConfigUpdate } from '@/lib/services/config.service';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { serverUrl, apiToken, libraryId } = body;
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
// Build updates array, skipping masked values
const updates: ConfigUpdate[] = [
{ key: 'audiobookshelf.server_url', value: serverUrl || '' },
{ key: 'audiobookshelf.library_id', value: libraryId || '' },
];
// Only update API token if it's not the masked placeholder
if (apiToken && !apiToken.startsWith('••••')) {
updates.push({
key: 'audiobookshelf.api_token',
value: apiToken,
encrypted: true,
});
}
// Update configuration
await configService.setMany(updates);
return NextResponse.json({
success: true,
message: 'Audiobookshelf settings saved successfully'
});
} catch (error) {
console.error('[Admin] Failed to save Audiobookshelf settings:', error);
return NextResponse.json(
{ error: 'Failed to save settings' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,135 @@
/**
* Component: Local Admin Password Change API
* Documentation: documentation/backend/services/auth.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireLocalAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import bcrypt from 'bcrypt';
/**
* POST /api/admin/settings/change-password
* Change password for local admin user
*
* Security:
* - Only available to local admin (isSetupAdmin=true AND plexId starts with 'local-')
* - Requires current password verification
* - New password must be at least 8 characters
* - New password must be different from current password
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireLocalAdmin(req, async (authenticatedReq: AuthenticatedRequest) => {
try {
const { currentPassword, newPassword, confirmPassword } = await request.json();
// Validate input
if (!currentPassword || !newPassword || !confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'All fields are required',
},
{ status: 400 }
);
}
// Validate new password length
if (newPassword.length < 8) {
return NextResponse.json(
{
success: false,
error: 'New password must be at least 8 characters',
},
{ status: 400 }
);
}
// Validate passwords match
if (newPassword !== confirmPassword) {
return NextResponse.json(
{
success: false,
error: 'New passwords do not match',
},
{ status: 400 }
);
}
// Validate new password is different from current
if (currentPassword === newPassword) {
return NextResponse.json(
{
success: false,
error: 'New password must be different from current password',
},
{ status: 400 }
);
}
// Get user from database
const user = await prisma.user.findUnique({
where: { id: authenticatedReq.user!.id },
select: {
id: true,
authToken: true,
plexId: true,
isSetupAdmin: true,
},
});
if (!user || !user.authToken) {
return NextResponse.json(
{
success: false,
error: 'User not found or invalid account type',
},
{ status: 404 }
);
}
// Verify current password
const currentPasswordValid = await bcrypt.compare(currentPassword, user.authToken);
if (!currentPasswordValid) {
return NextResponse.json(
{
success: false,
error: 'Current password is incorrect',
},
{ status: 400 }
);
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password in database
await prisma.user.update({
where: { id: user.id },
data: {
authToken: hashedPassword,
updatedAt: new Date(),
},
});
console.log(`[Auth] Local admin password changed successfully for user ${user.id}`);
return NextResponse.json({
success: true,
message: 'Password changed successfully',
});
} catch (error) {
console.error('[Auth] Failed to change password:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to change password',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,77 @@
/**
* Component: Admin Download Client Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { type, url, username, password } = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
{ error: 'Type, URL, username, and password are required' },
{ status: 400 }
);
}
// Validate type
if (type !== 'qbittorrent' && type !== 'transmission') {
return NextResponse.json(
{ error: 'Invalid client type. Must be qbittorrent or transmission' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'download_client_type' },
update: { value: type },
create: { key: 'download_client_type', value: type },
});
await prisma.configuration.upsert({
where: { key: 'download_client_url' },
update: { value: url },
create: { key: 'download_client_url', value: url },
});
await prisma.configuration.upsert({
where: { key: 'download_client_username' },
update: { value: username },
create: { key: 'download_client_username', value: username },
});
// Only update password if it's not the masked value
if (!password.startsWith('••••')) {
await prisma.configuration.upsert({
where: { key: 'download_client_password' },
update: { value: password },
create: { key: 'download_client_password', value: password },
});
}
console.log('[Admin] Download client settings updated');
return NextResponse.json({
success: true,
message: 'Download client settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update download client settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
+51
View File
@@ -0,0 +1,51 @@
/**
* OIDC Settings API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { enabled, providerName, issuerUrl, clientId, clientSecret } = body;
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
// Build config updates
const updates: Array<{key: string; value: string; encrypted?: boolean}> = [
{ key: 'oidc.enabled', value: enabled ? 'true' : 'false' },
{ key: 'oidc.provider_name', value: providerName || '' },
{ key: 'oidc.issuer_url', value: issuerUrl || '' },
{ key: 'oidc.client_id', value: clientId || '' },
];
// Only update client secret if provided (not masked)
if (clientSecret && !clientSecret.includes('••')) {
updates.push({
key: 'oidc.client_secret',
value: clientSecret,
encrypted: true
});
}
await configService.setMany(updates);
return NextResponse.json({
success: true,
message: 'OIDC settings saved successfully'
});
} catch (error) {
console.error('[Admin] Failed to save OIDC settings:', error);
return NextResponse.json(
{ error: 'Failed to save settings' },
{ status: 500 }
);
}
});
});
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Component: Admin Paths Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { downloadDir, mediaDir, metadataTaggingEnabled } = await request.json();
if (!downloadDir || !mediaDir) {
return NextResponse.json(
{ error: 'Download directory and media directory are required' },
{ status: 400 }
);
}
// Validate paths are not the same
if (downloadDir === mediaDir) {
return NextResponse.json(
{ error: 'Download and media directories must be different' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'download_dir' },
update: { value: downloadDir },
create: { key: 'download_dir', value: downloadDir },
});
await prisma.configuration.upsert({
where: { key: 'media_dir' },
update: { value: mediaDir },
create: { key: 'media_dir', value: mediaDir },
});
// Update metadata tagging setting
await prisma.configuration.upsert({
where: { key: 'metadata_tagging_enabled' },
update: { value: String(metadataTaggingEnabled ?? true) },
create: {
key: 'metadata_tagging_enabled',
value: String(metadataTaggingEnabled ?? true),
category: 'automation',
description: 'Automatically tag audio files with correct metadata during file organization',
},
});
console.log('[Admin] Paths settings updated');
return NextResponse.json({
success: true,
message: 'Paths settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update paths settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,66 @@
/**
* Component: Plex Libraries API Route
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getPlexService } from '@/lib/integrations/plex.service';
/**
* GET /api/admin/settings/plex/libraries
* Fetch available Plex libraries
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const plexService = await getPlexService();
// Get Plex configuration
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
const plexUrl = await configService.get('plex_url');
const plexToken = await configService.get('plex_token');
if (!plexUrl || !plexToken) {
return NextResponse.json(
{
success: false,
error: 'Plex not configured',
message: 'Please configure Plex URL and token first',
},
{ status: 400 }
);
}
// Fetch all libraries from Plex
const libraries = await plexService.getLibraries(plexUrl, plexToken);
// Filter for audiobook/music libraries (type 8 or 15)
const audioLibraries = libraries.filter((lib: any) =>
lib.type === 'artist' || lib.type === 'music' || lib.title.toLowerCase().includes('audio')
);
return NextResponse.json({
success: true,
libraries: audioLibraries.map((lib: any) => ({
id: lib.key,
title: lib.title,
type: lib.type,
})),
});
} catch (error) {
console.error('[Plex] Failed to fetch libraries:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch Plex libraries',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
});
});
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Component: Admin Plex Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getPlexService } from '@/lib/integrations/plex.service';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, token, libraryId } = await request.json();
if (!url || !token || !libraryId) {
return NextResponse.json(
{ error: 'URL, token, and library ID are required' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'plex_url' },
update: { value: url },
create: { key: 'plex_url', value: url },
});
// Only update token if it's not the masked value
if (!token.startsWith('••••')) {
await prisma.configuration.upsert({
where: { key: 'plex_token' },
update: { value: token },
create: { key: 'plex_token', value: token },
});
}
await prisma.configuration.upsert({
where: { key: 'plex_audiobook_library_id' },
update: { value: libraryId },
create: { key: 'plex_audiobook_library_id', value: libraryId },
});
// Fetch and save machine identifier (for server-specific access tokens)
// This is needed for BookDate per-user rating functionality
try {
const plexService = getPlexService();
const actualToken = token.startsWith('••••') ? null : token;
// Get token from DB if it was masked
const tokenToUse = actualToken || (await prisma.configuration.findUnique({
where: { key: 'plex_token' },
}))?.value;
if (tokenToUse) {
const serverInfo = await plexService.testConnection(url, tokenToUse);
if (serverInfo.success && serverInfo.info?.machineIdentifier) {
await prisma.configuration.upsert({
where: { key: 'plex_machine_identifier' },
update: { value: serverInfo.info.machineIdentifier },
create: { key: 'plex_machine_identifier', value: serverInfo.info.machineIdentifier },
});
console.log('[Admin] machineIdentifier updated:', serverInfo.info.machineIdentifier);
} else {
console.warn('[Admin] Could not fetch machineIdentifier');
}
}
} catch (error) {
console.error('[Admin] Error fetching machineIdentifier:', error);
// Don't fail the request if machineIdentifier fetch fails
}
console.log('[Admin] Plex settings updated');
return NextResponse.json({
success: true,
message: 'Plex settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update Plex settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,126 @@
/**
* Component: Prowlarr Indexers API Route
* Documentation: documentation/backend/api.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getProwlarrService } from '@/lib/integrations/prowlarr.service';
import { getConfigService } from '@/lib/services/config.service';
interface SavedIndexerConfig {
id: number;
name: string;
priority: number;
seedingTimeMinutes: number;
rssEnabled?: boolean;
}
/**
* GET /api/admin/settings/prowlarr/indexers
* Fetch available Prowlarr indexers with configuration
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const prowlarrService = await getProwlarrService();
const configService = getConfigService();
// Fetch indexers from Prowlarr
const indexers = await prowlarrService.getIndexers();
// Get saved indexer configuration (matches wizard format)
const savedConfigStr = await configService.get('prowlarr_indexers');
const savedIndexers: SavedIndexerConfig[] = savedConfigStr ? JSON.parse(savedConfigStr) : [];
// Merge with defaults (wizard format: array of {id, name, priority, seedingTimeMinutes})
const savedIndexersMap = new Map<number, SavedIndexerConfig>(
savedIndexers.map((idx) => [idx.id, idx])
);
const indexersWithConfig = indexers.map((indexer: any) => {
const saved = savedIndexersMap.get(indexer.id);
return {
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
privacy: indexer.privacy,
enabled: !!saved, // Enabled if in saved list
priority: saved?.priority || 10,
seedingTimeMinutes: saved?.seedingTimeMinutes ?? 0,
rssEnabled: saved?.rssEnabled ?? false,
supportsRss: indexer.capabilities?.supportsRss !== false, // Default to true if not specified
};
});
return NextResponse.json({
success: true,
indexers: indexersWithConfig,
});
} catch (error) {
console.error('[Prowlarr] Failed to fetch indexers:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch Prowlarr indexers',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
});
});
}
/**
* PUT /api/admin/settings/prowlarr/indexers
* Save indexer configuration
*/
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { indexers } = await req.json();
// Filter to only enabled indexers and convert to wizard format
const enabledIndexers = indexers
.filter((indexer: any) => indexer.enabled)
.map((indexer: any) => ({
id: indexer.id,
name: indexer.name,
priority: indexer.priority,
seedingTimeMinutes: indexer.seedingTimeMinutes,
rssEnabled: indexer.rssEnabled || false,
}));
// Save to configuration (matches wizard format)
const configService = getConfigService();
await configService.setMany([
{
key: 'prowlarr_indexers',
value: JSON.stringify(enabledIndexers),
category: 'indexer',
description: 'Prowlarr indexer settings (enabled, priority, seeding time)',
},
]);
return NextResponse.json({
success: true,
message: 'Indexer configuration saved',
});
} catch (error) {
console.error('[Prowlarr] Failed to save indexer config:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to save indexer configuration',
message: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,57 @@
/**
* Component: Admin Prowlarr Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, apiKey } = await request.json();
if (!url || !apiKey) {
return NextResponse.json(
{ error: 'URL and API key are required' },
{ status: 400 }
);
}
// Update configuration
await prisma.configuration.upsert({
where: { key: 'prowlarr_url' },
update: { value: url },
create: { key: 'prowlarr_url', value: url },
});
// Only update API key if it's not the masked value
if (!apiKey.startsWith('••••')) {
await prisma.configuration.upsert({
where: { key: 'prowlarr_api_key' },
update: { value: apiKey },
create: { key: 'prowlarr_api_key', value: apiKey },
});
}
console.log('[Admin] Prowlarr settings updated');
return NextResponse.json({
success: true,
message: 'Prowlarr settings updated successfully',
});
} catch (error) {
console.error('[Admin] Failed to update Prowlarr settings:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update settings',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,37 @@
/**
* Registration Settings API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
export async function PUT(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { enabled, requireAdminApproval } = body;
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
await configService.setMany([
{ key: 'auth.registration_enabled', value: enabled ? 'true' : 'false' },
{ key: 'auth.require_admin_approval', value: requireAdminApproval ? 'true' : 'false' },
]);
return NextResponse.json({
success: true,
message: 'Registration settings saved successfully'
});
} catch (error) {
console.error('[Admin] Failed to save registration settings:', error);
return NextResponse.json(
{ error: 'Failed to save settings' },
{ status: 500 }
);
}
});
});
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Component: Admin Settings API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
// Fetch all configuration
const configs = await prisma.configuration.findMany();
const configMap = new Map(configs.map((c) => [c.key, c.value]));
// Mask sensitive values
const maskValue = (key: string, value: string | null | undefined) => {
const sensitiveKeys = ['token', 'api_key', 'password'];
if (value && sensitiveKeys.some((k) => key.includes(k))) {
return '••••••••••••';
}
return value || '';
};
// Build response object
const settings = {
backendMode: configMap.get('system.backend_mode') || 'plex',
plex: {
url: configMap.get('plex_url') || '',
token: maskValue('token', configMap.get('plex_token')),
libraryId: configMap.get('plex_audiobook_library_id') || '',
},
audiobookshelf: {
serverUrl: configMap.get('audiobookshelf.server_url') || '',
apiToken: maskValue('api_token', configMap.get('audiobookshelf.api_token')),
libraryId: configMap.get('audiobookshelf.library_id') || '',
},
oidc: {
enabled: configMap.get('oidc.enabled') === 'true',
providerName: configMap.get('oidc.provider_name') || '',
issuerUrl: configMap.get('oidc.issuer_url') || '',
clientId: configMap.get('oidc.client_id') || '',
clientSecret: maskValue('client_secret', configMap.get('oidc.client_secret')),
},
registration: {
enabled: configMap.get('auth.registration_enabled') === 'true',
requireAdminApproval: configMap.get('auth.require_admin_approval') === 'true',
},
prowlarr: {
url: configMap.get('prowlarr_url') || '',
apiKey: maskValue('api_key', configMap.get('prowlarr_api_key')),
},
downloadClient: {
type: configMap.get('download_client_type') || 'qbittorrent',
url: configMap.get('download_client_url') || '',
username: configMap.get('download_client_username') || '',
password: maskValue('password', configMap.get('download_client_password')),
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
},
paths: {
downloadDir: configMap.get('download_dir') || '/downloads',
mediaDir: configMap.get('media_dir') || '/media/audiobooks',
metadataTaggingEnabled: configMap.get('metadata_tagging_enabled') === 'true',
},
general: {
appName: configMap.get('app_name') || 'ReadMeABook',
allowRegistrations: configMap.get('allow_registrations') === 'true',
maxConcurrentDownloads: parseInt(
configMap.get('max_concurrent_downloads') || '3'
),
autoApproveRequests: configMap.get('auto_approve_requests') === 'true',
},
};
return NextResponse.json(settings);
} catch (error) {
console.error('[Admin] Failed to fetch settings:', error);
return NextResponse.json(
{ error: 'Failed to fetch settings' },
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,71 @@
/**
* Component: Admin Settings Test Download Client API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { QBittorrentService } from '@/lib/integrations/qbittorrent.service';
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { type, url, username, password } = await request.json();
if (!type || !url || !username || !password) {
return NextResponse.json(
{ success: false, error: 'All fields are required' },
{ status: 400 }
);
}
if (type !== 'qbittorrent') {
return NextResponse.json(
{ success: false, error: 'Only qBittorrent is currently supported' },
{ status: 400 }
);
}
// If password is masked, fetch the actual value from database
let actualPassword = password;
if (password.startsWith('••••')) {
const storedPassword = await prisma.configuration.findUnique({
where: { key: 'download_client_password' },
});
if (!storedPassword?.value) {
return NextResponse.json(
{ success: false, error: 'No stored password found. Please re-enter your download client password.' },
{ status: 400 }
);
}
actualPassword = storedPassword.value;
}
// Test connection with credentials
const version = await QBittorrentService.testConnectionWithCredentials(
url,
username,
actualPassword
);
return NextResponse.json({
success: true,
version,
});
} catch (error) {
console.error('[Admin Settings] Download client test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to download client',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,84 @@
/**
* Component: Admin Settings Test Plex API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getPlexService } from '@/lib/integrations/plex.service';
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, token } = await request.json();
if (!url || !token) {
return NextResponse.json(
{ success: false, error: 'URL and token are required' },
{ status: 400 }
);
}
// If token is masked, fetch the actual value from database
let actualToken = token;
if (token.startsWith('••••')) {
const storedToken = await prisma.configuration.findUnique({
where: { key: 'plex_token' },
});
if (!storedToken?.value) {
return NextResponse.json(
{ success: false, error: 'No stored token found. Please re-enter your Plex token.' },
{ status: 400 }
);
}
actualToken = storedToken.value;
}
const plexService = getPlexService();
// Test connection and get server info
const connectionResult = await plexService.testConnection(url, actualToken);
if (!connectionResult.success || !connectionResult.info) {
return NextResponse.json(
{ success: false, error: connectionResult.message },
{ status: 400 }
);
}
// Get libraries
const libraries = await plexService.getLibraries(url, actualToken);
// Format server name safely
const serverName = connectionResult.info
? `${connectionResult.info.platform || 'Plex Server'} v${connectionResult.info.version || 'Unknown'}`
: 'Plex Server';
return NextResponse.json({
success: true,
serverName,
version: connectionResult.info?.version || 'Unknown',
machineIdentifier: connectionResult.info?.machineIdentifier || 'unknown',
libraries: libraries.map((lib) => ({
id: lib.id,
title: lib.title,
type: lib.type,
})),
});
} catch (error) {
console.error('[Admin Settings] Plex test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Plex',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,73 @@
/**
* Component: Admin Settings Test Prowlarr API
* Documentation: documentation/settings-pages.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { ProwlarrService } from '@/lib/integrations/prowlarr.service';
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { url, apiKey } = await request.json();
if (!url || !apiKey) {
return NextResponse.json(
{ success: false, error: 'URL and API key are required' },
{ status: 400 }
);
}
// If API key is masked, fetch the actual value from database
let actualApiKey = apiKey;
if (apiKey.startsWith('••••')) {
const storedApiKey = await prisma.configuration.findUnique({
where: { key: 'prowlarr_api_key' },
});
if (!storedApiKey?.value) {
return NextResponse.json(
{ success: false, error: 'No stored API key found. Please re-enter your Prowlarr API key.' },
{ status: 400 }
);
}
actualApiKey = storedApiKey.value;
}
// Create a new ProwlarrService instance with test credentials
const prowlarrService = new ProwlarrService(url, actualApiKey);
// Test connection and get indexers
const indexers = await prowlarrService.getIndexers();
// Only return enabled indexers
const enabledIndexers = indexers.filter((indexer) => indexer.enable);
return NextResponse.json({
success: true,
indexerCount: enabledIndexers.length,
totalIndexers: indexers.length,
indexers: enabledIndexers.map((indexer) => ({
id: indexer.id,
name: indexer.name,
protocol: indexer.protocol,
supportsRss: indexer.capabilities?.supportsRss !== false,
})),
});
} catch (error) {
console.error('[Admin Settings] Prowlarr test failed:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to connect to Prowlarr',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,75 @@
/**
* User Approval API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const body = await request.json();
const { approve } = body; // true = approve, false = reject
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
plexUsername: true,
registrationStatus: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
if (user.registrationStatus !== 'pending_approval') {
return NextResponse.json(
{ error: 'User is not pending approval' },
{ status: 400 }
);
}
if (approve) {
// Approve user
await prisma.user.update({
where: { id },
data: { registrationStatus: 'approved' },
});
return NextResponse.json({
success: true,
message: `User ${user.plexUsername} has been approved`
});
} else {
// Reject user (delete the account)
await prisma.user.delete({
where: { id },
});
return NextResponse.json({
success: true,
message: `User ${user.plexUsername} has been rejected and removed`
});
}
} catch (error) {
console.error('[Admin] Failed to approve/reject user:', error);
return NextResponse.json(
{ error: 'Failed to process user approval' },
{ status: 500 }
);
}
});
});
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Component: Admin User Update API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const body = await request.json();
const { role } = body;
// Validate role
if (!role || (role !== 'user' && role !== 'admin')) {
return NextResponse.json(
{ error: 'Invalid role. Must be "user" or "admin"' },
{ status: 400 }
);
}
// Prevent user from demoting themselves
if (req.user && id === req.user.sub) {
return NextResponse.json(
{ error: 'You cannot change your own role' },
{ status: 403 }
);
}
// Check if user is the setup admin
const targetUser = await prisma.user.findUnique({
where: { id },
select: {
isSetupAdmin: true,
plexUsername: true,
},
});
if (!targetUser) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
// Prevent changing setup admin role
if (targetUser.isSetupAdmin && role !== 'admin') {
return NextResponse.json(
{ error: 'Cannot change the setup admin role. This account must always remain an admin.' },
{ status: 403 }
);
}
// Update user role
const updatedUser = await prisma.user.update({
where: { id },
data: { role },
select: {
id: true,
plexUsername: true,
role: true,
},
});
return NextResponse.json({ user: updatedUser });
} catch (error) {
console.error('[Admin] Failed to update user:', error);
return NextResponse.json(
{ error: 'Failed to update user' },
{ status: 500 }
);
}
});
});
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Pending Users API
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const pendingUsers = await prisma.user.findMany({
where: {
registrationStatus: 'pending_approval'
},
select: {
id: true,
plexUsername: true,
plexEmail: true,
authProvider: true,
createdAt: true,
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json({ users: pendingUsers });
} catch (error) {
console.error('[Admin] Failed to fetch pending users:', error);
return NextResponse.json(
{ error: 'Failed to fetch pending users' },
{ status: 500 }
);
}
});
});
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Component: Admin Users API
* Documentation: documentation/admin-dashboard.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const users = await prisma.user.findMany({
select: {
id: true,
plexId: true,
plexUsername: true,
plexEmail: true,
role: true,
isSetupAdmin: true,
avatarUrl: true,
createdAt: true,
updatedAt: true,
lastLoginAt: true,
_count: {
select: {
requests: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
return NextResponse.json({ users });
} catch (error) {
console.error('[Admin] Failed to fetch users:', error);
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
);
}
});
});
}