Optional qBittorrent creds; require SABnzbd key

Allow qBittorrent to be configured without credentials (supports IP whitelist) and require an API key for SABnzbd. Skip connection testing when disabling a client. Updates include: validation changes in admin and setup API routes, test-download-client flows, DownloadClientModal UI validation and save/test logic, and DownloadClientManager to pass empty strings for optional credentials. Tests updated to reflect SABnzbd API key requirement.
This commit is contained in:
kikootwo
2026-02-03 13:30:51 -05:00
parent c559f8ebe9
commit 863f8466ea
8 changed files with 47 additions and 44 deletions
@@ -77,12 +77,16 @@ export async function PUT(
} }
} }
// Test connection if credentials/URL changed // Test connection if credentials/URL changed (skip if disabling client)
const isDisabling = enabled === false;
if ( if (
!isDisabling &&
(
url !== undefined || url !== undefined ||
username !== undefined || username !== undefined ||
(password && password !== '********') || (password && password !== '********') ||
disableSSLVerify !== undefined disableSSLVerify !== undefined
)
) { ) {
const testResult = await manager.testConnection(updatedClient); const testResult = await manager.testConnection(updatedClient);
if (!testResult.success) { if (!testResult.success) {
@@ -72,17 +72,18 @@ export async function POST(request: NextRequest) {
} }
// Validate required fields // Validate required fields
if (!name || !url || !password) { // Name and URL always required; password/API key only required for SABnzbd
// qBittorrent supports IP whitelist auth (no credentials needed)
if (!name || !url) {
return NextResponse.json( return NextResponse.json(
{ error: 'Name, URL, and password/API key are required' }, { error: 'Name and URL are required' },
{ status: 400 } { status: 400 }
); );
} }
// qBittorrent requires username if (type === 'sabnzbd' && !password) {
if (type === 'qbittorrent' && !username) {
return NextResponse.json( return NextResponse.json(
{ error: 'Username is required for qBittorrent' }, { error: 'API key is required for SABnzbd' },
{ status: 400 } { status: 400 }
); );
} }
@@ -111,14 +112,15 @@ export async function POST(request: NextRequest) {
} }
// Create new client config // Create new client config
// qBittorrent credentials are optional (supports IP whitelist auth)
const newClient: DownloadClientConfig = { const newClient: DownloadClientConfig = {
id: randomUUID(), id: randomUUID(),
type, type,
name, name,
enabled: true, enabled: true,
url, url,
username: username || undefined, username: username || '',
password, password: password || '',
disableSSLVerify: disableSSLVerify || false, disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false, remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined, remotePath: remotePath || undefined,
@@ -66,29 +66,32 @@ export async function POST(request: NextRequest) {
} }
// Validate required fields // Validate required fields
if (!url || !effectivePassword) { // URL is always required; password/API key only required for SABnzbd
// qBittorrent supports IP whitelist auth (no credentials needed)
if (!url) {
return NextResponse.json( return NextResponse.json(
{ error: 'URL and password/API key are required' }, { error: 'URL is required' },
{ status: 400 } { status: 400 }
); );
} }
if (type === 'qbittorrent' && !effectiveUsername) { if (type === 'sabnzbd' && !effectivePassword) {
return NextResponse.json( return NextResponse.json(
{ error: 'Username is required for qBittorrent' }, { error: 'API key is required for SABnzbd' },
{ status: 400 } { status: 400 }
); );
} }
// Create temporary client config for testing // Create temporary client config for testing
// qBittorrent credentials are optional (supports IP whitelist auth)
const testConfig: DownloadClientConfig = { const testConfig: DownloadClientConfig = {
id: 'test', id: 'test',
type, type,
name: 'Test Client', name: 'Test Client',
enabled: true, enabled: true,
url, url,
username: effectiveUsername || undefined, username: effectiveUsername || '',
password: effectivePassword, password: effectivePassword || '',
disableSSLVerify: disableSSLVerify || false, disableSSLVerify: disableSSLVerify || false,
remotePathMappingEnabled: remotePathMappingEnabled || false, remotePathMappingEnabled: remotePathMappingEnabled || false,
remotePath: remotePath || undefined, remotePath: remotePath || undefined,
+6 -4
View File
@@ -107,16 +107,18 @@ export async function POST(request: NextRequest) {
} }
// Validate each client has required fields // Validate each client has required fields
// qBittorrent credentials are optional (supports IP whitelist auth)
// SABnzbd always requires API key
for (const client of clients) { for (const client of clients) {
if (!client.url || !client.password) { if (!client.url) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Download client URL and password/API key are required' }, { success: false, error: 'Download client URL is required' },
{ status: 400 } { status: 400 }
); );
} }
if (client.type === 'qbittorrent' && !client.username) { if (client.type === 'sabnzbd' && !client.password) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'qBittorrent username is required' }, { success: false, error: 'SABnzbd API key is required' },
{ status: 400 } { status: 400 }
); );
} }
@@ -29,19 +29,13 @@ export async function POST(request: NextRequest) {
} }
// Validate required fields per client type // Validate required fields per client type
// qBittorrent credentials are optional (supports IP whitelist auth)
if (type === 'qbittorrent') { if (type === 'qbittorrent') {
if (!username || !password) { // Test qBittorrent connection (empty credentials work with IP whitelist)
return NextResponse.json(
{ success: false, error: 'Username and password are required for qBittorrent' },
{ status: 400 }
);
}
// Test qBittorrent connection
const version = await QBittorrentService.testConnectionWithCredentials( const version = await QBittorrentService.testConnectionWithCredentials(
url, url,
username, username || '',
password, password || '',
disableSSLVerify || false disableSSLVerify || false
); );
@@ -108,12 +108,9 @@ export function DownloadClientModal({
newErrors.url = 'URL is required'; newErrors.url = 'URL is required';
} }
if (type === 'qbittorrent' && !username.trim()) { // SABnzbd always requires API key; qBittorrent credentials are optional (supports IP whitelist auth)
newErrors.username = 'Username is required for qBittorrent'; if (type === 'sabnzbd' && (!password.trim() || (mode === 'add' && password === '********'))) {
} newErrors.password = 'API key is required';
if (!password.trim() || (mode === 'add' && password === '********')) {
newErrors.password = type === 'qbittorrent' ? 'Password is required' : 'API key is required';
} }
if (remotePathMappingEnabled) { if (remotePathMappingEnabled) {
@@ -196,7 +193,8 @@ export function DownloadClientModal({
return; return;
} }
if (!testResult?.success) { // Skip connection test requirement when disabling the client
if (!testResult?.success && enabled) {
setErrors({ ...errors, test: 'Please test the connection before saving' }); setErrors({ ...errors, test: 'Please test the connection before saving' });
return; return;
} }
@@ -439,7 +437,7 @@ export function DownloadClientModal({
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving || !testResult?.success} disabled={saving || (!testResult?.success && enabled)}
> >
{saving ? 'Saving...' : mode === 'add' ? 'Add Client' : 'Save Changes'} {saving ? 'Saving...' : mode === 'add' ? 'Add Client' : 'Save Changes'}
</Button> </Button>
@@ -184,7 +184,7 @@ export class DownloadClientManager {
return new QBittorrentService( return new QBittorrentService(
config.url, config.url,
config.username || '', config.username || '',
config.password, config.password || '', // Optional for IP whitelist auth
'/downloads', // defaultSavePath '/downloads', // defaultSavePath
config.category || 'readmeabook', // defaultCategory config.category || 'readmeabook', // defaultCategory
config.disableSSLVerify, config.disableSSLVerify,
+3 -3
View File
@@ -163,15 +163,15 @@ describe('Setup test routes', () => {
expect(payload.error).toMatch(/Invalid client type/); expect(payload.error).toMatch(/Invalid client type/);
}); });
it('rejects missing qBittorrent credentials', async () => { it('rejects missing SABnzbd API key', async () => {
const { POST } = await import('@/app/api/setup/test-download-client/route'); const { POST } = await import('@/app/api/setup/test-download-client/route');
const response = await POST({ const response = await POST({
json: vi.fn().mockResolvedValue({ type: 'qbittorrent', url: 'http://qbt' }), json: vi.fn().mockResolvedValue({ type: 'sabnzbd', url: 'http://sab' }),
} as any); } as any);
const payload = await response.json(); const payload = await response.json();
expect(response.status).toBe(400); expect(response.status).toBe(400);
expect(payload.error).toMatch(/Username and password/); expect(payload.error).toMatch(/API key/);
}); });
it('tests SABnzbd connection', async () => { it('tests SABnzbd connection', async () => {