mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Improve user auth handling and download monitoring
Adds detection of local users for authentication validation and login, prevents role changes for OIDC users, and clarifies user management UI. Enhances active downloads API to include speed and ETA from qBittorrent, and improves file path handling in download monitoring. Also updates torrent tagging and user info returned by APIs.
This commit is contained in:
@@ -31,6 +31,7 @@ interface IndexerConfig {
|
||||
|
||||
interface Settings {
|
||||
backendMode: 'plex' | 'audiobookshelf';
|
||||
hasLocalUsers: boolean;
|
||||
plex: {
|
||||
url: string;
|
||||
token: string;
|
||||
@@ -777,12 +778,12 @@ export default function AdminSettings() {
|
||||
break;
|
||||
|
||||
case 'auth':
|
||||
// Validate: In Audiobookshelf mode, at least one auth method must be enabled
|
||||
// Validate: In Audiobookshelf mode, at least one auth method must be enabled OR local users must exist
|
||||
if (settings.backendMode === 'audiobookshelf') {
|
||||
if (!settings.oidc.enabled && !settings.registration.enabled) {
|
||||
if (!settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers) {
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: 'At least one authentication method must be enabled (OIDC or Manual Registration). Otherwise, users will not be able to log in.',
|
||||
text: 'At least one authentication method must be enabled (OIDC or Manual Registration) since no local users exist. Otherwise, you will be locked out of the system.',
|
||||
});
|
||||
setSaving(false);
|
||||
return;
|
||||
@@ -2296,8 +2297,8 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning: No auth methods enabled */}
|
||||
{settings.backendMode === 'audiobookshelf' && !settings.oidc.enabled && !settings.registration.enabled && (
|
||||
{/* Warning: No auth methods enabled AND no local users exist */}
|
||||
{settings.backendMode === 'audiobookshelf' && !settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -2305,11 +2306,30 @@ export default function AdminSettings() {
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-red-800 dark:text-red-200">
|
||||
No Authentication Methods Enabled
|
||||
No Authentication Methods Available
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||
You must enable at least one authentication method (OIDC or Manual Registration).
|
||||
If you save with both disabled, users will not be able to log in to the system.
|
||||
You must enable at least one authentication method (OIDC or Manual Registration) since no local users exist.
|
||||
Saving with both disabled will lock you out of the system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info: Registration disabled but local users can still log in */}
|
||||
{settings.backendMode === 'audiobookshelf' && !settings.oidc.enabled && !settings.registration.enabled && settings.hasLocalUsers && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-800 dark:text-blue-200">
|
||||
Manual Registration Disabled
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
New user registration is disabled. Existing local users can still log in with their credentials.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2501,10 +2521,12 @@ export default function AdminSettings() {
|
||||
return !validated.audiobookshelf;
|
||||
}
|
||||
}
|
||||
// For Auth tab: disable if no auth methods are enabled in Audiobookshelf mode
|
||||
// For Auth tab: disable if no auth methods are enabled AND no local users exist in Audiobookshelf mode
|
||||
if (activeTab === 'auth' && settings) {
|
||||
if (settings.backendMode === 'audiobookshelf') {
|
||||
return !settings.oidc.enabled && !settings.registration.enabled;
|
||||
// Allow disabling both if local users exist (they can still log in)
|
||||
// Prevent disabling both if no local users exist (would lock out system)
|
||||
return !settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface User {
|
||||
plexEmail: string;
|
||||
role: 'user' | 'admin';
|
||||
isSetupAdmin: boolean;
|
||||
authProvider: string | null;
|
||||
avatarUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -247,7 +248,7 @@ function AdminUsersPageContent() {
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
@@ -287,8 +288,11 @@ function AdminUsersPageContent() {
|
||||
<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
|
||||
className="text-sm text-gray-500 dark:text-gray-400 cursor-help"
|
||||
title={`Full ID: ${user.plexId}`}
|
||||
>
|
||||
ID: {user.plexId.length > 12 ? `${user.plexId.substring(0, 12)}...` : user.plexId}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -332,6 +336,13 @@ function AdminUsersPageContent() {
|
||||
</svg>
|
||||
<span>Protected</span>
|
||||
</span>
|
||||
) : user.authProvider === 'oidc' ? (
|
||||
<span className="inline-flex items-center gap-1 text-gray-400 dark:text-gray-600 cursor-not-allowed" title="OIDC user roles are managed by the identity provider (use admin role mapping in settings)">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>OIDC Managed</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => showEditDialog(user)}
|
||||
@@ -365,6 +376,8 @@ function AdminUsersPageContent() {
|
||||
<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>• <strong>OIDC Users:</strong> Role management is handled by the identity provider - use admin role mapping in OIDC settings</li>
|
||||
<li>• <strong>Local Users:</strong> Can be freely assigned user or admin roles (except setup admin)</li>
|
||||
<li>• You cannot change your own role for security reasons</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getQBittorrentService } from '@/lib/integrations/qbittorrent.service';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
@@ -17,7 +18,11 @@ export async function GET(request: NextRequest) {
|
||||
status: 'downloading',
|
||||
deletedAt: null,
|
||||
},
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
progress: true,
|
||||
updatedAt: true,
|
||||
audiobook: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -42,6 +47,7 @@ export async function GET(request: NextRequest) {
|
||||
select: {
|
||||
downloadStatus: true,
|
||||
torrentName: true,
|
||||
torrentHash: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -51,20 +57,67 @@ export async function GET(request: NextRequest) {
|
||||
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,
|
||||
}));
|
||||
// Get qBittorrent service
|
||||
let qbService;
|
||||
try {
|
||||
qbService = await getQBittorrentService();
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to initialize qBittorrent service:', error);
|
||||
// Return downloads without speed/eta if qBittorrent is unavailable
|
||||
const formatted = activeDownloads.map((download) => ({
|
||||
requestId: download.id,
|
||||
title: download.audiobook.title,
|
||||
author: download.audiobook.author,
|
||||
status: download.status,
|
||||
progress: download.progress,
|
||||
speed: 0,
|
||||
eta: null,
|
||||
torrentName: download.downloadHistory[0]?.torrentName || null,
|
||||
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
|
||||
user: download.user.plexUsername,
|
||||
startedAt: download.updatedAt,
|
||||
}));
|
||||
return NextResponse.json({ downloads: formatted });
|
||||
}
|
||||
|
||||
return NextResponse.json({ downloads: formatted });
|
||||
// Format response with speed and ETA from qBittorrent
|
||||
const formatted = await Promise.all(
|
||||
activeDownloads.map(async (download) => {
|
||||
let speed = 0;
|
||||
let eta: number | null = null;
|
||||
|
||||
// Get torrent hash from download history
|
||||
const torrentHash = download.downloadHistory[0]?.torrentHash;
|
||||
|
||||
// Fetch torrent info from qBittorrent if we have a hash
|
||||
if (torrentHash) {
|
||||
try {
|
||||
const torrentInfo = await qbService.getTorrent(torrentHash);
|
||||
speed = torrentInfo.dlspeed;
|
||||
eta = torrentInfo.eta > 0 ? torrentInfo.eta : null;
|
||||
} catch (error) {
|
||||
// Torrent not found or other error - use defaults
|
||||
console.error(`[Admin] Failed to get torrent info for ${torrentHash}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestId: download.id,
|
||||
title: download.audiobook.title,
|
||||
author: download.audiobook.author,
|
||||
status: download.status,
|
||||
progress: download.progress,
|
||||
speed,
|
||||
eta,
|
||||
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(
|
||||
|
||||
@@ -15,6 +15,11 @@ export async function GET(request: NextRequest) {
|
||||
const configs = await prisma.configuration.findMany();
|
||||
const configMap = new Map(configs.map((c) => [c.key, c.value]));
|
||||
|
||||
// Check if any local users exist (for validation)
|
||||
const hasLocalUsers = (await prisma.user.count({
|
||||
where: { authProvider: 'local' }
|
||||
})) > 0;
|
||||
|
||||
// Mask sensitive values
|
||||
const maskValue = (key: string, value: string | null | undefined) => {
|
||||
const sensitiveKeys = ['token', 'api_key', 'password', 'secret'];
|
||||
@@ -27,6 +32,7 @@ export async function GET(request: NextRequest) {
|
||||
// Build response object
|
||||
const settings = {
|
||||
backendMode: configMap.get('system.backend_mode') || 'plex',
|
||||
hasLocalUsers,
|
||||
plex: {
|
||||
url: configMap.get('plex_url') || '',
|
||||
token: maskValue('token', configMap.get('plex_token')),
|
||||
|
||||
@@ -34,11 +34,12 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user is the setup admin
|
||||
// Check if user is the setup admin or OIDC user
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
isSetupAdmin: true,
|
||||
authProvider: true,
|
||||
plexUsername: true,
|
||||
},
|
||||
});
|
||||
@@ -58,6 +59,14 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent changing OIDC user roles (managed by identity provider)
|
||||
if (targetUser.authProvider === 'oidc') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot change OIDC user roles. Use admin role mapping in OIDC settings instead.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update user role
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id },
|
||||
|
||||
@@ -19,6 +19,7 @@ export async function GET(request: NextRequest) {
|
||||
plexEmail: true,
|
||||
role: true,
|
||||
isSetupAdmin: true,
|
||||
authProvider: true,
|
||||
avatarUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ConfigurationService } from '@/lib/services/config.service';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@@ -17,22 +18,36 @@ export async function GET() {
|
||||
const registrationEnabled = (await configService.get('auth.registration_enabled')) === 'true';
|
||||
const oidcProviderName = await configService.get('oidc.provider_name') || 'SSO';
|
||||
|
||||
// Check if any local users exist in database (for login form visibility)
|
||||
const hasLocalUsers = (await prisma.user.count({
|
||||
where: { authProvider: 'local' }
|
||||
})) > 0;
|
||||
|
||||
const providers: string[] = [];
|
||||
if (oidcEnabled) providers.push('oidc');
|
||||
if (registrationEnabled) providers.push('local');
|
||||
if (hasLocalUsers) providers.push('local');
|
||||
|
||||
return NextResponse.json({
|
||||
backendMode: 'audiobookshelf',
|
||||
providers,
|
||||
registrationEnabled,
|
||||
hasLocalUsers,
|
||||
oidcProviderName: oidcEnabled ? oidcProviderName : null,
|
||||
});
|
||||
} else {
|
||||
// Plex mode
|
||||
// Plex mode - check if local admin exists (setup admin)
|
||||
const hasLocalUsers = (await prisma.user.count({
|
||||
where: {
|
||||
plexId: { startsWith: 'local-' },
|
||||
isSetupAdmin: true
|
||||
}
|
||||
})) > 0;
|
||||
|
||||
return NextResponse.json({
|
||||
backendMode: 'plex',
|
||||
providers: ['plex'],
|
||||
registrationEnabled: false,
|
||||
hasLocalUsers,
|
||||
oidcProviderName: null,
|
||||
});
|
||||
}
|
||||
@@ -43,6 +58,7 @@ export async function GET() {
|
||||
backendMode: 'plex',
|
||||
providers: ['plex'],
|
||||
registrationEnabled: false,
|
||||
hasLocalUsers: false,
|
||||
oidcProviderName: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ function LoginContent() {
|
||||
backendMode: string;
|
||||
providers: string[];
|
||||
registrationEnabled: boolean;
|
||||
hasLocalUsers: boolean;
|
||||
oidcProviderName: string | null;
|
||||
} | null>(null);
|
||||
const [showRegisterForm, setShowRegisterForm] = useState(false);
|
||||
@@ -72,6 +73,7 @@ function LoginContent() {
|
||||
backendMode: 'plex',
|
||||
providers: ['plex'],
|
||||
registrationEnabled: false,
|
||||
hasLocalUsers: false,
|
||||
oidcProviderName: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface TorrentInfo {
|
||||
category: string;
|
||||
tags: string;
|
||||
save_path: string;
|
||||
content_path?: string; // Absolute path to torrent content (file or directory)
|
||||
completion_on: number; // Unix timestamp
|
||||
added_on: number;
|
||||
seeding_time?: number; // Seconds spent seeding
|
||||
|
||||
@@ -44,11 +44,7 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
|
||||
const torrentHash = await qbt.addTorrent(torrent.downloadUrl, {
|
||||
category: 'readmeabook',
|
||||
tags: [
|
||||
'audiobook',
|
||||
`request-${requestId}`,
|
||||
`audiobook-${audiobook.id}`,
|
||||
],
|
||||
tags: ['audiobook'], // Generic tag for all audiobooks
|
||||
sequentialDownload: true, // Download in order for potential streaming
|
||||
paused: false, // Start immediately
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Documentation: documentation/phase3/README.md
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queue.service';
|
||||
import { prisma } from '../db';
|
||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||
@@ -89,11 +90,20 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
|
||||
// Get torrent files to find download path
|
||||
const files = await qbt.getFiles(downloadClientId);
|
||||
const downloadPath = torrent.save_path;
|
||||
|
||||
await logger?.info(`Downloaded to: ${downloadPath}`, {
|
||||
// Determine actual content path for file organization
|
||||
// Priority 1: Use content_path if provided by qBittorrent (most reliable)
|
||||
// Priority 2: Construct path using path.join() for proper normalization
|
||||
const organizePath = torrent.content_path
|
||||
? torrent.content_path
|
||||
: path.join(torrent.save_path, torrent.name);
|
||||
|
||||
await logger?.info(`Download completed`, {
|
||||
filesCount: files.length,
|
||||
torrentName: torrent.name,
|
||||
savePath: torrent.save_path,
|
||||
contentPath: torrent.content_path || '(not provided)',
|
||||
organizePath,
|
||||
});
|
||||
|
||||
// Update download history to completed
|
||||
@@ -120,12 +130,12 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
throw new Error('Request or audiobook not found or deleted');
|
||||
}
|
||||
|
||||
// Trigger organize files job (target path determined by database config)
|
||||
// Trigger organize files job with properly constructed path
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addOrganizeJob(
|
||||
requestId,
|
||||
request.audiobook.id,
|
||||
`${downloadPath}/${torrent.name}`
|
||||
organizePath
|
||||
);
|
||||
|
||||
await logger?.info(`Triggered organize_files job for request ${requestId}`);
|
||||
@@ -136,7 +146,7 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
message: 'Download completed, organizing files',
|
||||
requestId,
|
||||
progress: 100,
|
||||
downloadPath,
|
||||
downloadPath: organizePath,
|
||||
};
|
||||
} else if (progress.state === 'failed') {
|
||||
await logger?.error(`Download failed for request ${requestId}`);
|
||||
|
||||
Reference in New Issue
Block a user