/** * 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'; import { getClientDisplayName } from '@/lib/interfaces/download-client.interface'; import { requireSetupIncomplete } from '@/lib/middleware/auth'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('API.Setup.Complete'); export async function POST(request: NextRequest) { return requireSetupIncomplete(request, async (req) => { try { const { backendMode, audibleRegion, admin, plex, audiobookshelf, authMethod, oidc, registration, prowlarr, downloadClient, paths, bookdate, } = await req.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 || !paths?.download_dir || !paths?.media_dir ) { return NextResponse.json( { success: false, error: 'Missing required configuration fields' }, { status: 400 } ); } // Validate download client(s) if (!downloadClient) { return NextResponse.json( { success: false, error: 'Download client configuration is required' }, { status: 400 } ); } // Support both legacy single client and new multi-client array const clients = Array.isArray(downloadClient) ? downloadClient : [downloadClient]; if (clients.length === 0) { return NextResponse.json( { success: false, error: 'At least one download client must be configured' }, { status: 400 } ); } // Validate each client has required fields // qBittorrent credentials are optional (supports IP whitelist auth) // SABnzbd always requires API key for (const client of clients) { if (!client.url) { return NextResponse.json( { success: false, error: 'Download client URL is required' }, { status: 400 } ); } if (client.type === 'sabnzbd' && !client.password) { return NextResponse.json( { success: false, error: 'SABnzbd API key is required' }, { 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 }, }); // Save Audible region (default to 'us' if not provided) await prisma.configuration.upsert({ where: { key: 'audible.region' }, update: { value: audibleRegion || 'us', category: 'system' }, create: { key: 'audible.region', value: audibleRegion || 'us', category: 'system' }, }); if (backendMode === 'plex') { // Plex configuration await prisma.configuration.upsert({ where: { key: 'plex_url' }, update: { value: plex.url }, create: { key: 'plex_url', value: plex.url }, }); const encryptedPlexToken = encryptionService.encrypt(plex.token); await prisma.configuration.upsert({ where: { key: 'plex_token' }, update: { value: encryptedPlexToken, encrypted: true }, create: { key: 'plex_token', value: encryptedPlexToken, encrypted: true }, }); 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; logger.debug('Fetched machineIdentifier', { machineIdentifier }); } else { logger.warn('Could not fetch machineIdentifier'); } } catch (error) { logger.error('Error fetching machineIdentifier', { error: error instanceof Error ? error.message : String(error) }); } } if (machineIdentifier) { await prisma.configuration.upsert({ where: { key: 'plex_machine_identifier' }, update: { value: machineIdentifier }, create: { key: 'plex_machine_identifier', value: machineIdentifier }, }); } // Save trigger_scan_after_import setting await prisma.configuration.upsert({ where: { key: 'plex.trigger_scan_after_import' }, update: { value: plex.trigger_scan_after_import === true ? 'true' : 'false' }, create: { key: 'plex.trigger_scan_after_import', value: plex.trigger_scan_after_import === true ? 'true' : 'false' }, }); } 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 }, }); // Save trigger_scan_after_import setting await prisma.configuration.upsert({ where: { key: 'audiobookshelf.trigger_scan_after_import' }, update: { value: audiobookshelf.trigger_scan_after_import === true ? 'true' : 'false' }, create: { key: 'audiobookshelf.trigger_scan_after_import', value: audiobookshelf.trigger_scan_after_import === true ? 'true' : 'false' }, }); // 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 }, }); // Access control configuration await prisma.configuration.upsert({ where: { key: 'oidc.access_control_method' }, update: { value: oidc.access_control_method || 'open' }, create: { key: 'oidc.access_control_method', value: oidc.access_control_method || 'open' }, }); await prisma.configuration.upsert({ where: { key: 'oidc.access_group_claim' }, update: { value: oidc.access_group_claim || 'groups' }, create: { key: 'oidc.access_group_claim', value: oidc.access_group_claim || 'groups' }, }); await prisma.configuration.upsert({ where: { key: 'oidc.access_group_value' }, update: { value: oidc.access_group_value || '' }, create: { key: 'oidc.access_group_value', value: oidc.access_group_value || '' }, }); await prisma.configuration.upsert({ where: { key: 'oidc.allowed_emails' }, update: { value: oidc.allowed_emails || '[]' }, create: { key: 'oidc.allowed_emails', value: oidc.allowed_emails || '[]' }, }); await prisma.configuration.upsert({ where: { key: 'oidc.allowed_usernames' }, update: { value: oidc.allowed_usernames || '[]' }, create: { key: 'oidc.allowed_usernames', value: oidc.allowed_usernames || '[]' }, }); // Admin role mapping configuration await prisma.configuration.upsert({ where: { key: 'oidc.admin_claim_enabled' }, update: { value: oidc.admin_claim_enabled || 'false' }, create: { key: 'oidc.admin_claim_enabled', value: oidc.admin_claim_enabled || 'false' }, }); await prisma.configuration.upsert({ where: { key: 'oidc.admin_claim_name' }, update: { value: oidc.admin_claim_name || 'groups' }, create: { key: 'oidc.admin_claim_name', value: oidc.admin_claim_name || 'groups' }, }); await prisma.configuration.upsert({ where: { key: 'oidc.admin_claim_value' }, update: { value: oidc.admin_claim_value || '' }, create: { key: 'oidc.admin_claim_value', value: oidc.admin_claim_value || '' }, }); } // 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 }, }); const encryptedProwlarrApiKey = encryptionService.encrypt(prowlarr.api_key); await prisma.configuration.upsert({ where: { key: 'prowlarr_api_key' }, update: { value: encryptedProwlarrApiKey, encrypted: true }, create: { key: 'prowlarr_api_key', value: encryptedProwlarrApiKey, encrypted: true }, }); await prisma.configuration.upsert({ where: { key: 'prowlarr_indexers' }, update: { value: JSON.stringify(prowlarr.indexers) }, create: { key: 'prowlarr_indexers', value: JSON.stringify(prowlarr.indexers) }, }); // Download clients configuration (multi-client support) // Accept either legacy single client or new clients array let downloadClientsArray: any[]; if (Array.isArray(downloadClient)) { // New format: array of clients downloadClientsArray = downloadClient; } else if (downloadClient && typeof downloadClient === 'object') { // Legacy format: convert single client to array downloadClientsArray = [{ id: `temp-${Date.now()}`, type: downloadClient.type, name: getClientDisplayName(downloadClient.type), enabled: true, url: downloadClient.url, username: downloadClient.username, password: downloadClient.password, disableSSLVerify: downloadClient.disableSSLVerify || false, remotePathMappingEnabled: downloadClient.remotePathMappingEnabled || false, remotePath: downloadClient.remotePath, localPath: downloadClient.localPath, category: 'readmeabook', }]; } else { throw new Error('Invalid download client configuration'); } // Encrypt passwords in download clients const encryptedClients = downloadClientsArray.map(client => ({ ...client, password: client.password ? encryptionService.encrypt(client.password) : '', })); await prisma.configuration.upsert({ where: { key: 'download_clients' }, update: { value: JSON.stringify(encryptedClients) }, create: { key: 'download_clients', value: JSON.stringify(encryptedClients) }, }); // 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' }, }); // Chapter merging configuration await prisma.configuration.upsert({ where: { key: 'chapter_merging_enabled' }, update: { value: String(paths.chapter_merging_enabled ?? false) }, create: { key: 'chapter_merging_enabled', value: String(paths.chapter_merging_enabled ?? false), category: 'automation', description: 'Automatically merge multi-file chapter downloads into single M4B with chapter markers' }, }); // 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) { logger.info('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, isVerified: true, isEnabled: true, }, }); } else { // Create new global config await prisma.bookDateConfig.create({ data: { provider: bookdate.provider, apiKey: encryptedApiKey, model: bookdate.model, isVerified: true, isEnabled: true, }, }); } logger.debug('Global BookDate configuration saved'); } else { logger.debug('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, }, }); logger.debug('Auto jobs enabled'); logger.info('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) { logger.error('Failed to save configuration', { error: error instanceof Error ? error.message : String(error) }); return NextResponse.json( { success: false, error: error instanceof Error ? error.message : 'Failed to save configuration', }, { status: 500 } ); } }); }