mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
Add backend unit test framework and modularize settings UI
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Component: Admin Settings - Global Settings Hook
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { Settings, Message, ValidationState, TestResult } from '../lib/types';
|
||||
|
||||
/**
|
||||
* Global settings hook for managing settings state across all tabs
|
||||
* Provides centralized settings fetch/update logic
|
||||
*/
|
||||
export function useSettings() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [originalSettings, setOriginalSettings] = useState<Settings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [message, setMessage] = useState<Message | null>(null);
|
||||
const [validated, setValidated] = useState<ValidationState>({
|
||||
plex: false,
|
||||
audiobookshelf: false,
|
||||
oidc: false,
|
||||
registration: false,
|
||||
prowlarr: false,
|
||||
download: false,
|
||||
paths: false,
|
||||
});
|
||||
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
|
||||
|
||||
/**
|
||||
* Fetch settings from API
|
||||
*/
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/settings');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Convert OIDC allowed lists from JSON arrays to comma-separated strings for display
|
||||
if (data.oidc) {
|
||||
const parseArrayToCommaSeparated = (jsonStr: string): string => {
|
||||
try {
|
||||
const arr = JSON.parse(jsonStr);
|
||||
return Array.isArray(arr) ? arr.join(', ') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
data.oidc.allowedEmails = parseArrayToCommaSeparated(data.oidc.allowedEmails);
|
||||
data.oidc.allowedUsernames = parseArrayToCommaSeparated(data.oidc.allowedUsernames);
|
||||
}
|
||||
|
||||
setSettings(data);
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(data))); // Deep copy for comparison
|
||||
} else {
|
||||
console.error('Failed to fetch settings:', response.status, response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update settings (local state only, call saveSettings to persist)
|
||||
*/
|
||||
const updateSettings = useCallback((updates: Partial<Settings> | ((prev: Settings) => Settings)) => {
|
||||
setSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
if (typeof updates === 'function') {
|
||||
return updates(prev);
|
||||
}
|
||||
return { ...prev, ...updates };
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset settings to original values
|
||||
*/
|
||||
const resetSettings = useCallback(() => {
|
||||
if (originalSettings) {
|
||||
setSettings(JSON.parse(JSON.stringify(originalSettings)));
|
||||
}
|
||||
}, [originalSettings]);
|
||||
|
||||
/**
|
||||
* Check if settings have been modified
|
||||
*/
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
if (!settings || !originalSettings) return false;
|
||||
return JSON.stringify(settings) !== JSON.stringify(originalSettings);
|
||||
}, [settings, originalSettings]);
|
||||
|
||||
/**
|
||||
* Update validation state for a specific section
|
||||
*/
|
||||
const updateValidation = useCallback((section: keyof ValidationState, isValid: boolean) => {
|
||||
setValidated((prev) => ({ ...prev, [section]: isValid }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update test results for a specific section
|
||||
*/
|
||||
const updateTestResults = useCallback((section: string, result: TestResult) => {
|
||||
setTestResults((prev) => ({ ...prev, [section]: result }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Show a message banner
|
||||
*/
|
||||
const showMessage = useCallback((msg: Message) => {
|
||||
setMessage(msg);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear message banner
|
||||
*/
|
||||
const clearMessage = useCallback(() => {
|
||||
setMessage(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Mark settings as saved (update original settings)
|
||||
*/
|
||||
const markAsSaved = useCallback(() => {
|
||||
if (settings) {
|
||||
setOriginalSettings(JSON.parse(JSON.stringify(settings)));
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Fetch settings on mount
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
return {
|
||||
// State
|
||||
settings,
|
||||
originalSettings,
|
||||
loading,
|
||||
saving,
|
||||
testing,
|
||||
message,
|
||||
validated,
|
||||
testResults,
|
||||
|
||||
// Setters
|
||||
setSettings,
|
||||
setSaving,
|
||||
setTesting,
|
||||
|
||||
// Methods
|
||||
fetchSettings,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
hasUnsavedChanges,
|
||||
updateValidation,
|
||||
updateTestResults,
|
||||
showMessage,
|
||||
clearMessage,
|
||||
markAsSaved,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Component: Admin Settings - Helper Functions
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { Settings, SettingsTab, SavedIndexerConfig } from './types';
|
||||
import type { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
|
||||
|
||||
/**
|
||||
* Converts JSON array string to comma-separated string for display
|
||||
*/
|
||||
export const parseArrayToCommaSeparated = (jsonStr: string): string => {
|
||||
try {
|
||||
const arr = JSON.parse(jsonStr);
|
||||
return Array.isArray(arr) ? arr.join(', ') : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts comma-separated string to JSON array string for storage
|
||||
*/
|
||||
export const parseCommaSeparatedToArray = (str: string): string => {
|
||||
if (!str || str.trim() === '') return '[]';
|
||||
const items = str.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
return JSON.stringify(items);
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves settings for a specific tab
|
||||
*/
|
||||
export const saveTabSettings = async (
|
||||
activeTab: SettingsTab,
|
||||
settings: Settings,
|
||||
configuredIndexers: SavedIndexerConfig[],
|
||||
flagConfigs: IndexerFlagConfig[]
|
||||
): Promise<void> => {
|
||||
switch (activeTab) {
|
||||
case 'library':
|
||||
// Save Audible region
|
||||
await fetchWithAuth('/api/admin/settings/audible', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ region: settings.audibleRegion }),
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save Audible region settings');
|
||||
});
|
||||
|
||||
// Save backend-specific settings
|
||||
if (settings.backendMode === 'plex') {
|
||||
await fetchWithAuth('/api/admin/settings/plex', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings.plex),
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save Plex settings');
|
||||
});
|
||||
} else {
|
||||
await fetchWithAuth('/api/admin/settings/audiobookshelf', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings.audiobookshelf),
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save Audiobookshelf settings');
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth':
|
||||
// Save OIDC settings if enabled
|
||||
if (settings.oidc.enabled) {
|
||||
const oidcPayload = {
|
||||
...settings.oidc,
|
||||
allowedEmails: parseCommaSeparatedToArray(settings.oidc.allowedEmails),
|
||||
allowedUsernames: parseCommaSeparatedToArray(settings.oidc.allowedUsernames),
|
||||
};
|
||||
|
||||
await fetchWithAuth('/api/admin/settings/oidc', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(oidcPayload),
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save OIDC settings');
|
||||
});
|
||||
}
|
||||
|
||||
// Save registration settings
|
||||
await fetchWithAuth('/api/admin/settings/registration', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings.registration),
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save registration settings');
|
||||
});
|
||||
break;
|
||||
|
||||
case 'prowlarr':
|
||||
// Save Prowlarr URL and API key
|
||||
await fetchWithAuth('/api/admin/settings/prowlarr', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings.prowlarr),
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save Prowlarr settings');
|
||||
});
|
||||
|
||||
// Save indexer configuration and flag configs
|
||||
const indexersForSave = configuredIndexers.map(idx => ({ ...idx, enabled: true }));
|
||||
await fetchWithAuth('/api/admin/settings/prowlarr/indexers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ indexers: indexersForSave, flagConfigs }),
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save indexer configuration');
|
||||
});
|
||||
break;
|
||||
|
||||
case 'download':
|
||||
await fetchWithAuth('/api/admin/settings/download-client', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings.downloadClient),
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save download client settings');
|
||||
});
|
||||
break;
|
||||
|
||||
case 'paths':
|
||||
await fetchWithAuth('/api/admin/settings/paths', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings.paths),
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error('Failed to save paths settings');
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error('Unknown settings tab or tab handles its own saving');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that authentication is properly configured in Audiobookshelf mode
|
||||
*/
|
||||
export const validateAuthSettings = (settings: Settings): { valid: boolean; message?: string } => {
|
||||
if (settings.backendMode === 'audiobookshelf') {
|
||||
if (!settings.oidc.enabled && !settings.registration.enabled && !settings.hasLocalUsers) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets validation status for the current tab
|
||||
*/
|
||||
export const getTabValidation = (
|
||||
activeTab: SettingsTab,
|
||||
settings: Settings,
|
||||
validated: {
|
||||
plex: boolean;
|
||||
audiobookshelf: boolean;
|
||||
oidc: boolean;
|
||||
registration: boolean;
|
||||
prowlarr: boolean;
|
||||
download: boolean;
|
||||
paths: boolean;
|
||||
}
|
||||
): boolean => {
|
||||
switch (activeTab) {
|
||||
case 'library':
|
||||
return settings.backendMode === 'plex' ? validated.plex : validated.audiobookshelf;
|
||||
case 'auth':
|
||||
return validated.oidc || validated.registration;
|
||||
case 'prowlarr':
|
||||
return validated.prowlarr;
|
||||
case 'download':
|
||||
return validated.download;
|
||||
case 'paths':
|
||||
return validated.paths;
|
||||
case 'ebook':
|
||||
case 'bookdate':
|
||||
return true; // These tabs handle their own saving
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets tab configuration based on backend mode
|
||||
*/
|
||||
export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
|
||||
{ id: 'library' as const, label: backendMode === 'plex' ? 'Plex' : 'Audiobookshelf', icon: '📺' },
|
||||
...(backendMode === 'audiobookshelf' ? [{ id: 'auth' as const, label: 'Authentication', icon: '🔐' }] : []),
|
||||
{ id: 'prowlarr' as const, label: 'Indexers', icon: '🔍' },
|
||||
{ id: 'download' as const, label: 'Download Client', icon: '⬇️' },
|
||||
{ id: 'paths' as const, label: 'Paths', icon: '📁' },
|
||||
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
|
||||
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
|
||||
];
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Component: Admin Settings - Shared Types
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
/**
|
||||
* Main settings object structure
|
||||
*/
|
||||
export interface Settings {
|
||||
backendMode: 'plex' | 'audiobookshelf';
|
||||
hasLocalUsers: boolean;
|
||||
audibleRegion: string;
|
||||
plex: PlexSettings;
|
||||
audiobookshelf: AudiobookshelfSettings;
|
||||
oidc: OIDCSettings;
|
||||
registration: RegistrationSettings;
|
||||
prowlarr: ProwlarrSettings;
|
||||
downloadClient: DownloadClientSettings;
|
||||
paths: PathsSettings;
|
||||
ebook: EbookSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plex library configuration
|
||||
*/
|
||||
export interface PlexSettings {
|
||||
url: string;
|
||||
token: string;
|
||||
libraryId: string;
|
||||
triggerScanAfterImport: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audiobookshelf library configuration
|
||||
*/
|
||||
export interface AudiobookshelfSettings {
|
||||
serverUrl: string;
|
||||
apiToken: string;
|
||||
libraryId: string;
|
||||
triggerScanAfterImport: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC authentication configuration
|
||||
*/
|
||||
export interface OIDCSettings {
|
||||
enabled: boolean;
|
||||
providerName: string;
|
||||
issuerUrl: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
accessControlMethod: string;
|
||||
accessGroupClaim: string;
|
||||
accessGroupValue: string;
|
||||
allowedEmails: string;
|
||||
allowedUsernames: string;
|
||||
adminClaimEnabled: boolean;
|
||||
adminClaimName: string;
|
||||
adminClaimValue: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual registration configuration
|
||||
*/
|
||||
export interface RegistrationSettings {
|
||||
enabled: boolean;
|
||||
requireAdminApproval: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prowlarr indexer configuration
|
||||
*/
|
||||
export interface ProwlarrSettings {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download client (qBittorrent) configuration
|
||||
*/
|
||||
export interface DownloadClientSettings {
|
||||
type: string;
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
disableSSLVerify: boolean;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File paths and processing configuration
|
||||
*/
|
||||
export interface PathsSettings {
|
||||
downloadDir: string;
|
||||
mediaDir: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
chapterMergingEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* E-book sidecar configuration
|
||||
*/
|
||||
export interface EbookSettings {
|
||||
enabled: boolean;
|
||||
preferredFormat: string;
|
||||
baseUrl: string;
|
||||
flaresolverrUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plex library item
|
||||
*/
|
||||
export interface PlexLibrary {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audiobookshelf library item
|
||||
*/
|
||||
export interface ABSLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prowlarr indexer configuration
|
||||
*/
|
||||
export interface IndexerConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: string;
|
||||
privacy: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled: boolean;
|
||||
categories?: number[];
|
||||
supportsRss?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved indexer configuration (subset for UI)
|
||||
*/
|
||||
export interface SavedIndexerConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending user awaiting approval
|
||||
*/
|
||||
export interface PendingUser {
|
||||
id: string;
|
||||
plexUsername: string;
|
||||
plexEmail: string | null;
|
||||
authProvider: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation state for all settings sections
|
||||
*/
|
||||
export interface ValidationState {
|
||||
plex?: boolean;
|
||||
audiobookshelf?: boolean;
|
||||
oidc?: boolean;
|
||||
registration?: boolean;
|
||||
prowlarr?: boolean;
|
||||
download?: boolean;
|
||||
paths?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test result for connection tests
|
||||
*/
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message/notification display
|
||||
*/
|
||||
export interface Message {
|
||||
type: 'success' | 'error';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BookDate AI provider configuration
|
||||
*/
|
||||
export interface BookDateConfig {
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
isEnabled: boolean;
|
||||
isVerified: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BookDate AI model option
|
||||
*/
|
||||
export interface BookDateModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab identifier type
|
||||
*/
|
||||
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate';
|
||||
+174
-2820
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Component: AuthTab - Authentication Settings
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { OIDCSection } from './OIDCSection';
|
||||
import { RegistrationSection } from './RegistrationSection';
|
||||
import { PendingUsersTable } from './PendingUsersTable';
|
||||
import { useAuthSettings } from './useAuthSettings';
|
||||
import type { Settings } from '../../lib/types';
|
||||
|
||||
interface AuthTabProps {
|
||||
settings: Settings;
|
||||
onChange: (settings: Settings) => void;
|
||||
onValidationChange: (section: string, isValid: boolean) => void;
|
||||
onSuccess: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export function AuthTab({
|
||||
settings,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
onSuccess,
|
||||
onError
|
||||
}: AuthTabProps) {
|
||||
const {
|
||||
pendingUsers,
|
||||
loadingPendingUsers,
|
||||
testing,
|
||||
oidcTestResult,
|
||||
fetchPendingUsers,
|
||||
testOIDCConnection,
|
||||
approveUser,
|
||||
} = useAuthSettings({ onSuccess, onError });
|
||||
|
||||
// Fetch pending users when the tab is loaded and registration with approval is enabled
|
||||
useEffect(() => {
|
||||
if (settings.registration.enabled && settings.registration.requireAdminApproval) {
|
||||
fetchPendingUsers();
|
||||
}
|
||||
}, [settings.registration.enabled, settings.registration.requireAdminApproval, fetchPendingUsers]);
|
||||
|
||||
const handleOIDCChange = (oidcSettings: typeof settings.oidc) => {
|
||||
onChange({
|
||||
...settings,
|
||||
oidc: oidcSettings,
|
||||
});
|
||||
onValidationChange('oidc', false);
|
||||
};
|
||||
|
||||
const handleRegistrationChange = (registrationSettings: typeof settings.registration) => {
|
||||
onChange({
|
||||
...settings,
|
||||
registration: registrationSettings,
|
||||
});
|
||||
onValidationChange('registration', false);
|
||||
};
|
||||
|
||||
const handleOIDCTest = async (issuerUrl: string, clientId: string, clientSecret: string) => {
|
||||
const isValid = await testOIDCConnection(issuerUrl, clientId, clientSecret);
|
||||
if (isValid) {
|
||||
onValidationChange('oidc', true);
|
||||
}
|
||||
return isValid;
|
||||
};
|
||||
|
||||
// Check if no auth methods are enabled and no local users exist
|
||||
const showNoAuthWarning = settings.backendMode === 'audiobookshelf' &&
|
||||
!settings.oidc.enabled &&
|
||||
!settings.registration.enabled &&
|
||||
!settings.hasLocalUsers;
|
||||
|
||||
// Check if registration is disabled but local users can still log in
|
||||
const showRegistrationDisabledInfo = settings.backendMode === 'audiobookshelf' &&
|
||||
!settings.oidc.enabled &&
|
||||
!settings.registration.enabled &&
|
||||
settings.hasLocalUsers;
|
||||
|
||||
// Show pending users table if registration with approval is enabled
|
||||
const showPendingUsers = settings.registration.enabled &&
|
||||
settings.registration.requireAdminApproval;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
{/* OIDC Settings Section */}
|
||||
<OIDCSection
|
||||
settings={settings.oidc}
|
||||
onChange={handleOIDCChange}
|
||||
onTest={handleOIDCTest}
|
||||
testing={testing}
|
||||
testResult={oidcTestResult}
|
||||
onValidationChange={() => onValidationChange('oidc', true)}
|
||||
/>
|
||||
|
||||
{/* Registration Settings Section */}
|
||||
<RegistrationSection
|
||||
settings={settings.registration}
|
||||
onChange={handleRegistrationChange}
|
||||
/>
|
||||
|
||||
{/* Warning: No auth methods enabled AND no local users exist */}
|
||||
{showNoAuthWarning && (
|
||||
<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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-red-800 dark:text-red-200">
|
||||
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) 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 */}
|
||||
{showRegistrationDisabledInfo && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending Users Section */}
|
||||
{showPendingUsers && (
|
||||
<PendingUsersTable
|
||||
pendingUsers={pendingUsers}
|
||||
loading={loadingPendingUsers}
|
||||
onApprove={approveUser}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Component: AuthTab - OIDC Configuration Section
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { OIDCSettings, TestResult } from '../../lib/types';
|
||||
|
||||
interface OIDCSectionProps {
|
||||
settings: OIDCSettings;
|
||||
onChange: (settings: OIDCSettings) => void;
|
||||
onTest: (issuerUrl: string, clientId: string, clientSecret: string) => Promise<boolean>;
|
||||
testing: boolean;
|
||||
testResult: TestResult | null;
|
||||
onValidationChange: () => void;
|
||||
}
|
||||
|
||||
export function OIDCSection({
|
||||
settings,
|
||||
onChange,
|
||||
onTest,
|
||||
testing,
|
||||
testResult,
|
||||
onValidationChange
|
||||
}: OIDCSectionProps) {
|
||||
const handleTestConnection = async () => {
|
||||
const isValid = await onTest(settings.issuerUrl, settings.clientId, settings.clientSecret);
|
||||
if (isValid) {
|
||||
onValidationChange();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
OIDC Authentication
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure OpenID Connect (OIDC) authentication for single sign-on with Authentik, Keycloak, or other providers.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable OIDC Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="oidc-enabled"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, enabled: e.target.checked });
|
||||
}}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="oidc-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable OIDC Authentication
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Allow users to log in using an external OIDC provider
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.enabled && (
|
||||
<>
|
||||
{/* Provider Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Provider Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.providerName}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, providerName: e.target.value });
|
||||
}}
|
||||
placeholder="Authentik"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Display name for the login button
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Issuer URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Issuer URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={settings.issuerUrl}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, issuerUrl: e.target.value });
|
||||
}}
|
||||
placeholder="https://auth.example.com/application/o/readmeabook/"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
OIDC provider's issuer URL (must support .well-known/openid-configuration)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Client ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client ID
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.clientId}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, clientId: e.target.value });
|
||||
}}
|
||||
placeholder="readmeabook-client"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client Secret */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Secret
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.clientSecret}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, clientSecret: e.target.value });
|
||||
}}
|
||||
placeholder="Enter client secret"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test Connection Button */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<Button
|
||||
onClick={handleTestConnection}
|
||||
loading={testing}
|
||||
disabled={!settings.issuerUrl || !settings.clientId || !settings.clientSecret}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test OIDC Configuration
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Access Control
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Control who can log in to your application. This is separate from admin permissions.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Access Control Method */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Access Control Method
|
||||
</label>
|
||||
<select
|
||||
value={settings.accessControlMethod}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, accessControlMethod: e.target.value });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="open">Open Access (anyone can log in)</option>
|
||||
<option value="group_claim">Group/Claim Based</option>
|
||||
<option value="allowed_list">Allowed List (emails/usernames)</option>
|
||||
<option value="admin_approval">Admin Approval Required</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{settings.accessControlMethod === 'open' && 'Anyone who can authenticate with your OIDC provider will have access'}
|
||||
{settings.accessControlMethod === 'group_claim' && 'Only users with a specific group/claim can access'}
|
||||
{settings.accessControlMethod === 'allowed_list' && 'Only explicitly allowed users can access'}
|
||||
{settings.accessControlMethod === 'admin_approval' && 'New users must be approved by an admin before access is granted'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Group/Claim Based Controls */}
|
||||
{settings.accessControlMethod === 'group_claim' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Group Claim Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.accessGroupClaim}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, accessGroupClaim: e.target.value });
|
||||
}}
|
||||
placeholder="groups"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
The OIDC claim field that contains group membership (usually "groups" or "roles")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Required Group
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.accessGroupValue}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, accessGroupValue: e.target.value });
|
||||
}}
|
||||
placeholder="readmeabook-users"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Users must be in this group to access the application
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Allowed List Controls */}
|
||||
{settings.accessControlMethod === 'allowed_list' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Allowed Emails (comma-separated)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.allowedEmails}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, allowedEmails: e.target.value });
|
||||
}}
|
||||
placeholder="user1@example.com, user2@example.com"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Enter email addresses separated by commas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Allowed Usernames (comma-separated)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.allowedUsernames}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, allowedUsernames: e.target.value });
|
||||
}}
|
||||
placeholder="john_doe, jane_smith"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Enter usernames separated by commas
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Role Mapping Section */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 mt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Admin Role Mapping
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Automatically grant admin permissions based on OIDC claims (e.g., group membership). The first user will always become admin.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable Admin Claim Mapping */}
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="admin-claim-enabled"
|
||||
checked={settings.adminClaimEnabled}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, adminClaimEnabled: e.target.checked });
|
||||
}}
|
||||
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="admin-claim-enabled"
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
Enable Admin Role Mapping
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Automatically grant admin role to users with specific OIDC claim values
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.adminClaimEnabled && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Admin Claim Name
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.adminClaimName}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, adminClaimName: e.target.value });
|
||||
}}
|
||||
placeholder="groups"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
The OIDC claim field to check for admin role (usually "groups" or "roles")
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Admin Claim Value
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.adminClaimValue}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, adminClaimValue: e.target.value });
|
||||
}}
|
||||
placeholder="readmeabook-admin"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Users with this value in their claim will be granted admin role
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Example Configuration */}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 rounded-lg p-4 border border-amber-200 dark:border-amber-800">
|
||||
<div className="flex gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
Example Configuration
|
||||
</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
In Authentik: Create a group called "readmeabook-admin", add users to it, and set "Admin Claim Value" to "readmeabook-admin"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Component: AuthTab - Pending Users Table
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { PendingUser } from '../../lib/types';
|
||||
|
||||
interface PendingUsersTableProps {
|
||||
pendingUsers: PendingUser[];
|
||||
loading: boolean;
|
||||
onApprove: (userId: string, approve: boolean) => void;
|
||||
}
|
||||
|
||||
export function PendingUsersTable({ pendingUsers, loading, onApprove }: PendingUsersTableProps) {
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Pending User Approvals
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Review and approve or reject user registration requests.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||
<span className="text-sm text-gray-500">Loading pending users...</span>
|
||||
</div>
|
||||
) : pendingUsers.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{pendingUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{user.plexUsername}
|
||||
</h3>
|
||||
{user.plexEmail && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{user.plexEmail}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
Registered: {new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => onApprove(user.id, true)}
|
||||
variant="outline"
|
||||
className="border-green-300 text-green-600 hover:bg-green-50 dark:border-green-700 dark:text-green-400 dark:hover:bg-green-900/20"
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onApprove(user.id, false)}
|
||||
variant="outline"
|
||||
className="border-red-300 text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
No pending user approvals
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Component: AuthTab - Manual Registration Section
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import type { RegistrationSettings } from '../../lib/types';
|
||||
|
||||
interface RegistrationSectionProps {
|
||||
settings: RegistrationSettings;
|
||||
onChange: (settings: RegistrationSettings) => void;
|
||||
}
|
||||
|
||||
export function RegistrationSection({ settings, onChange }: RegistrationSectionProps) {
|
||||
return (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Manual Registration
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure manual user registration settings.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable Registration Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="registration-enabled"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, enabled: e.target.checked });
|
||||
}}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="registration-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable Manual Registration
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Allow users to create accounts manually with username/password
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Require Admin Approval Toggle */}
|
||||
{settings.enabled && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="require-approval"
|
||||
checked={settings.requireAdminApproval}
|
||||
onChange={(e) => {
|
||||
onChange({ ...settings, requireAdminApproval: e.target.checked });
|
||||
}}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="require-approval"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Require Admin Approval
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
New users must be approved by an admin before they can log in
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Component: AuthTab - Export File
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
export { AuthTab } from './AuthTab';
|
||||
export { OIDCSection } from './OIDCSection';
|
||||
export { RegistrationSection } from './RegistrationSection';
|
||||
export { PendingUsersTable } from './PendingUsersTable';
|
||||
export { useAuthSettings } from './useAuthSettings';
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Component: AuthTab - Custom Hook
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { PendingUser, TestResult } from '../../lib/types';
|
||||
|
||||
interface UseAuthSettingsProps {
|
||||
onSuccess: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export function useAuthSettings({ onSuccess, onError }: UseAuthSettingsProps) {
|
||||
const [pendingUsers, setPendingUsers] = useState<PendingUser[]>([]);
|
||||
const [loadingPendingUsers, setLoadingPendingUsers] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [oidcTestResult, setOidcTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch pending users awaiting approval
|
||||
*/
|
||||
const fetchPendingUsers = useCallback(async () => {
|
||||
setLoadingPendingUsers(true);
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/users/pending');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPendingUsers(data.users || []);
|
||||
} else {
|
||||
console.error('Failed to fetch pending users:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pending users:', error);
|
||||
} finally {
|
||||
setLoadingPendingUsers(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Test OIDC connection configuration
|
||||
*/
|
||||
const testOIDCConnection = useCallback(async (issuerUrl: string, clientId: string, clientSecret: string) => {
|
||||
setTesting(true);
|
||||
setOidcTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/setup/test-oidc', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
issuerUrl,
|
||||
clientId,
|
||||
clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setOidcTestResult({ success: true, message: 'OIDC configuration is valid' });
|
||||
onSuccess('OIDC configuration is valid. You can now save.');
|
||||
return true;
|
||||
} else {
|
||||
setOidcTestResult({ success: false, message: data.error || 'Connection failed' });
|
||||
onError(data.error || 'Failed to validate OIDC configuration');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to test OIDC connection';
|
||||
setOidcTestResult({ success: false, message: errorMsg });
|
||||
onError(errorMsg);
|
||||
return false;
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [onSuccess, onError]);
|
||||
|
||||
/**
|
||||
* Approve or reject a pending user
|
||||
*/
|
||||
const approveUser = useCallback(async (userId: string, approve: boolean) => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/api/admin/users/${userId}/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approve }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
onSuccess(data.message);
|
||||
// Refresh pending users list
|
||||
await fetchPendingUsers();
|
||||
} else {
|
||||
onError(data.error || 'Failed to process user approval');
|
||||
}
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : 'Failed to process user approval');
|
||||
}
|
||||
}, [onSuccess, onError, fetchPendingUsers]);
|
||||
|
||||
return {
|
||||
pendingUsers,
|
||||
loadingPendingUsers,
|
||||
testing,
|
||||
oidcTestResult,
|
||||
fetchPendingUsers,
|
||||
testOIDCConnection,
|
||||
approveUser,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Component: BookDate Settings Tab
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useBookDateSettings } from './useBookDateSettings';
|
||||
|
||||
interface BookDateTabProps {
|
||||
onSuccess: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export function BookDateTab({ onSuccess, onError }: BookDateTabProps) {
|
||||
const {
|
||||
provider,
|
||||
apiKey,
|
||||
model,
|
||||
baseUrl,
|
||||
enabled,
|
||||
configured,
|
||||
models,
|
||||
testing,
|
||||
saving,
|
||||
clearingSwipes,
|
||||
setProvider,
|
||||
setApiKey,
|
||||
setModel,
|
||||
setBaseUrl,
|
||||
setEnabled,
|
||||
setModels,
|
||||
testConnection,
|
||||
saveConfig,
|
||||
clearSwipes,
|
||||
} = useBookDateSettings();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
BookDate Configuration
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure global AI-powered audiobook recommendations. All users share this API key, but receive personalized recommendations based on their individual library and ratings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable/Disable Toggle */}
|
||||
{configured && (
|
||||
<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 items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-1">
|
||||
BookDate Feature
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{enabled ? 'Feature is currently enabled' : 'Feature is currently disabled'}
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Provider */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
AI Provider
|
||||
</label>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => {
|
||||
setProvider(e.target.value);
|
||||
setModels([]);
|
||||
setBaseUrl('');
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude (Anthropic)</option>
|
||||
<option value="custom">Custom (OpenAI-compatible)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Base URL Input - Show for Custom Provider */}
|
||||
{provider === 'custom' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Base URL <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={(e) => {
|
||||
setBaseUrl(e.target.value);
|
||||
setModels([]);
|
||||
}}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
|
||||
Examples:
|
||||
<br />• Ollama: <code>http://localhost:11434/v1</code>
|
||||
<br />• LM Studio: <code>http://localhost:1234/v1</code>
|
||||
<br />• vLLM: <code>http://localhost:8000/v1</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{provider === 'custom' ? 'API Key (Optional for local models)' : 'API Key'}
|
||||
{provider !== 'custom' && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
setModels([]);
|
||||
}}
|
||||
placeholder={
|
||||
provider === 'custom'
|
||||
? 'Leave blank for local models'
|
||||
: configured
|
||||
? '••••••••••••••••'
|
||||
: (provider === 'openai' ? 'sk-...' : 'sk-ant-...')
|
||||
}
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{provider === 'custom'
|
||||
? 'Optional: Leave blank if your endpoint does not require authentication (e.g., Ollama, LM Studio)'
|
||||
: 'The API key is stored securely and encrypted. Leave blank to keep existing key.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Test Connection Button */}
|
||||
<Button
|
||||
onClick={() => testConnection(onSuccess, onError)}
|
||||
loading={testing}
|
||||
disabled={
|
||||
provider === 'custom'
|
||||
? !baseUrl.trim()
|
||||
: (!apiKey.trim() && !configured)
|
||||
}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{configured && !apiKey.trim()
|
||||
? 'Test Connection & Fetch Models (using saved API key)'
|
||||
: 'Test Connection & Fetch Models'}
|
||||
</Button>
|
||||
|
||||
{/* Model Selection */}
|
||||
{models.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Select Model
|
||||
</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">-- Choose a model --</option>
|
||||
{models.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note about per-user settings */}
|
||||
{(models.length > 0 || configured) && model && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||
<strong>Note:</strong> Library scope and custom prompt preferences are now configured per-user.
|
||||
Users can adjust these settings in their BookDate preferences (settings icon on the BookDate page).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
{model && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={() => saveConfig(onSuccess, onError)}
|
||||
loading={saving}
|
||||
disabled={!model}
|
||||
className="w-full"
|
||||
>
|
||||
Save BookDate Configuration
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Clear Swipe History */}
|
||||
{configured && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Clear All Swipe History
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Remove all swipe history and cached recommendations for ALL users. This will reset everyone's BookDate recommendations.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => clearSwipes(onSuccess, onError)}
|
||||
loading={clearingSwipes}
|
||||
variant="outline"
|
||||
className="border-red-300 text-red-600 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Clear Swipe History
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BookDateTab } from './BookDateTab';
|
||||
export { useBookDateSettings } from './useBookDateSettings';
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Component: BookDate Settings Tab - Custom Hook
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { BookDateModel } from '../../lib/types';
|
||||
|
||||
export function useBookDateSettings() {
|
||||
const [provider, setProvider] = useState<string>('openai');
|
||||
const [apiKey, setApiKey] = useState<string>('');
|
||||
const [model, setModel] = useState<string>('');
|
||||
const [baseUrl, setBaseUrl] = useState<string>('');
|
||||
const [enabled, setEnabled] = useState<boolean>(true);
|
||||
const [configured, setConfigured] = useState<boolean>(false);
|
||||
const [models, setModels] = useState<BookDateModel[]>([]);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [clearingSwipes, setClearingSwipes] = useState(false);
|
||||
|
||||
/**
|
||||
* Fetch BookDate configuration
|
||||
*/
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/bookdate/config');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.config) {
|
||||
setProvider(data.config.provider || 'openai');
|
||||
setModel(data.config.model || '');
|
||||
setBaseUrl(data.config.baseUrl || '');
|
||||
setEnabled(data.config.isEnabled !== false);
|
||||
setConfigured(data.config.isVerified || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load BookDate config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Test connection and fetch available models
|
||||
*/
|
||||
const testConnection = async (onSuccess: (msg: string) => void, onError: (msg: string) => void) => {
|
||||
const hasApiKey = apiKey.trim().length > 0;
|
||||
|
||||
// Validation
|
||||
if (provider === 'custom') {
|
||||
if (!baseUrl.trim()) {
|
||||
onError('Please enter a base URL for custom provider');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!hasApiKey && !configured) {
|
||||
onError('Please enter an API key');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setTesting(true);
|
||||
|
||||
try {
|
||||
const payload: any = {
|
||||
provider,
|
||||
};
|
||||
|
||||
if (hasApiKey) {
|
||||
payload.apiKey = apiKey;
|
||||
} else if (provider !== 'custom') {
|
||||
payload.useSavedKey = true;
|
||||
}
|
||||
|
||||
if (provider === 'custom') {
|
||||
payload.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
const response = await fetchWithAuth('/api/bookdate/test-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Connection test failed');
|
||||
}
|
||||
|
||||
setModels(data.models || []);
|
||||
onSuccess('Connection successful! Please select a model.');
|
||||
|
||||
// Auto-select first model if none selected
|
||||
if (!model && data.models?.length > 0) {
|
||||
setModel(data.models[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : 'Connection test failed');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save BookDate configuration
|
||||
*/
|
||||
const saveConfig = async (onSuccess: (msg: string) => void, onError: (msg: string) => void) => {
|
||||
// Validate: model is required
|
||||
if (!model) {
|
||||
onError('Please select a model');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: baseUrl required for custom provider
|
||||
if (provider === 'custom') {
|
||||
if (!baseUrl.trim()) {
|
||||
onError('Please enter a base URL for custom provider');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const hasApiKey = apiKey.trim().length > 0;
|
||||
if (!configured && !hasApiKey) {
|
||||
onError('Please enter an API key for initial setup');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const hasApiKey = apiKey.trim().length > 0;
|
||||
const payload: any = {
|
||||
provider,
|
||||
model,
|
||||
isEnabled: enabled,
|
||||
};
|
||||
|
||||
if (hasApiKey) {
|
||||
payload.apiKey = apiKey;
|
||||
}
|
||||
|
||||
if (provider === 'custom') {
|
||||
payload.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
const response = await fetchWithAuth('/api/bookdate/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to save configuration');
|
||||
}
|
||||
|
||||
onSuccess('BookDate configuration saved successfully!');
|
||||
setConfigured(true);
|
||||
setApiKey(''); // Clear API key from UI after save
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : 'Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all swipe history
|
||||
*/
|
||||
const clearSwipes = async (onSuccess: (msg: string) => void, onError: (msg: string) => void) => {
|
||||
if (!confirm('This will clear all swipe history. Continue?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setClearingSwipes(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/bookdate/swipes', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to clear swipe history');
|
||||
}
|
||||
|
||||
onSuccess('Swipe history cleared successfully!');
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : 'Failed to clear swipe history');
|
||||
} finally {
|
||||
setClearingSwipes(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle provider change
|
||||
*/
|
||||
const handleProviderChange = (newProvider: string) => {
|
||||
setProvider(newProvider);
|
||||
setModels([]);
|
||||
setBaseUrl('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
provider,
|
||||
apiKey,
|
||||
model,
|
||||
baseUrl,
|
||||
enabled,
|
||||
configured,
|
||||
models,
|
||||
testing,
|
||||
saving,
|
||||
clearingSwipes,
|
||||
setProvider: handleProviderChange,
|
||||
setApiKey,
|
||||
setModel,
|
||||
setBaseUrl,
|
||||
setEnabled,
|
||||
setModels,
|
||||
testConnection,
|
||||
saveConfig,
|
||||
clearSwipes,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Component: Download Client Settings Tab
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useDownloadSettings } from './useDownloadSettings';
|
||||
import type { DownloadClientSettings } from '../../lib/types';
|
||||
|
||||
interface DownloadTabProps {
|
||||
downloadClient: DownloadClientSettings;
|
||||
onChange: (settings: DownloadClientSettings) => void;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
}
|
||||
|
||||
export function DownloadTab({ downloadClient, onChange, onValidationChange }: DownloadTabProps) {
|
||||
const { testing, testResult, updateField, handleTypeChange, testConnection } = useDownloadSettings({
|
||||
downloadClient,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Download Client
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure your download client: qBittorrent for torrents or SABnzbd for Usenet/NZB downloads.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Type
|
||||
</label>
|
||||
<select
|
||||
value={downloadClient.type}
|
||||
onChange={(e) => handleTypeChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="qbittorrent">qBittorrent</option>
|
||||
<option value="sabnzbd">SABnzbd</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Server URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={downloadClient.url}
|
||||
onChange={(e) => updateField('url', e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* qBittorrent: Username + Password */}
|
||||
{downloadClient.type === 'qbittorrent' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Username
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={downloadClient.username}
|
||||
onChange={(e) => updateField('username', e.target.value)}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Password
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={downloadClient.password}
|
||||
onChange={(e) => updateField('password', e.target.value)}
|
||||
placeholder="Enter password"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* SABnzbd: API Key only */}
|
||||
{downloadClient.type === 'sabnzbd' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={downloadClient.password}
|
||||
onChange={(e) => updateField('password', e.target.value)}
|
||||
placeholder="Enter SABnzbd API key"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Find this in SABnzbd under Config → General → API Key
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSL Verification Toggle */}
|
||||
{downloadClient.url.startsWith('https') && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 border border-yellow-200 dark:border-yellow-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="disable-ssl-verify"
|
||||
checked={downloadClient.disableSSLVerify}
|
||||
onChange={(e) => updateField('disableSSLVerify', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="disable-ssl-verify"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Disable SSL Certificate Verification
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Enable this if you're using a self-signed certificate or getting SSL errors.
|
||||
<span className="text-yellow-700 dark:text-yellow-500 font-medium"> ⚠️ Only use on trusted private networks.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remote-path-mapping"
|
||||
checked={downloadClient.remotePathMappingEnabled}
|
||||
onChange={(e) => updateField('remotePathMappingEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="remote-path-mapping"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable Remote Path Mapping
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 font-mono">
|
||||
Example: Remote <span className="text-blue-600 dark:text-blue-400">/remote/mnt/d/done</span> → Local <span className="text-green-600 dark:text-green-400">/downloads</span>
|
||||
</p>
|
||||
|
||||
{/* Warning for existing downloads */}
|
||||
{downloadClient.remotePathMappingEnabled && (
|
||||
<div className="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ <strong>Note:</strong> Path mapping only affects new downloads. In-progress downloads will continue using their original paths.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditional Fields */}
|
||||
{downloadClient.remotePathMappingEnabled && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Remote Path (from qBittorrent)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/remote/mnt/d/done"
|
||||
value={downloadClient.remotePath}
|
||||
onChange={(e) => updateField('remotePath', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The path prefix as reported by qBittorrent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Local Path (for ReadMeABook)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/downloads"
|
||||
value={downloadClient.localPath}
|
||||
onChange={(e) => updateField('localPath', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The actual path where files are accessible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
disabled={
|
||||
!downloadClient.url ||
|
||||
!downloadClient.password ||
|
||||
(downloadClient.type === 'qbittorrent' && !downloadClient.username)
|
||||
}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Component: Download Client Settings Tab - Export
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
export { DownloadTab } from './DownloadTab';
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Component: Download Client Settings Tab - Custom Hook
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { DownloadClientSettings, TestResult } from '../../lib/types';
|
||||
|
||||
interface UseDownloadSettingsProps {
|
||||
downloadClient: DownloadClientSettings;
|
||||
onChange: (settings: DownloadClientSettings) => void;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
}
|
||||
|
||||
export function useDownloadSettings({ downloadClient, onChange, onValidationChange }: UseDownloadSettingsProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
const updateField = (field: keyof DownloadClientSettings, value: string | boolean) => {
|
||||
onChange({ ...downloadClient, [field]: value });
|
||||
onValidationChange(false);
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: string) => {
|
||||
onChange({
|
||||
...downloadClient,
|
||||
type,
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
onValidationChange(false);
|
||||
};
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/settings/test-download-client', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: downloadClient.type,
|
||||
url: downloadClient.url,
|
||||
username: downloadClient.username,
|
||||
password: downloadClient.password,
|
||||
disableSSLVerify: downloadClient.disableSSLVerify,
|
||||
remotePathMappingEnabled: downloadClient.remotePathMappingEnabled,
|
||||
remotePath: downloadClient.remotePath,
|
||||
localPath: downloadClient.localPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const result: TestResult = {
|
||||
success: true,
|
||||
message: `Connected to ${downloadClient.type} (${data.version || 'version unknown'})`
|
||||
};
|
||||
setTestResult(result);
|
||||
onValidationChange(true);
|
||||
return result;
|
||||
} else {
|
||||
const result: TestResult = {
|
||||
success: false,
|
||||
message: data.error || 'Connection failed'
|
||||
};
|
||||
setTestResult(result);
|
||||
onValidationChange(false);
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
const result: TestResult = {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to test connection'
|
||||
};
|
||||
setTestResult(result);
|
||||
onValidationChange(false);
|
||||
return result;
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
testing,
|
||||
testResult,
|
||||
updateField,
|
||||
handleTypeChange,
|
||||
testConnection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Component: E-book Settings Tab
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useEbookSettings } from './useEbookSettings';
|
||||
import type { EbookSettings } from '../../lib/types';
|
||||
|
||||
interface EbookTabProps {
|
||||
ebook: EbookSettings;
|
||||
onChange: (ebook: EbookSettings) => void;
|
||||
onSuccess: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
markAsSaved: () => void;
|
||||
}
|
||||
|
||||
export function EbookTab({ ebook, onChange, onSuccess, onError, markAsSaved }: EbookTabProps) {
|
||||
const {
|
||||
saving,
|
||||
testingFlaresolverr,
|
||||
flaresolverrTestResult,
|
||||
updateEbook,
|
||||
testFlaresolverrConnection,
|
||||
saveSettings,
|
||||
} = useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSaved });
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
E-book Sidecar
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Automatically download e-books from Anna's Archive to accompany your audiobooks.
|
||||
E-books are placed in the same folder as the audiobook files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ebook-enabled"
|
||||
checked={ebook.enabled || false}
|
||||
onChange={(e) => updateEbook('enabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="ebook-enabled"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable e-book sidecar downloads
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
When enabled, the system will search for e-books matching your audiobook's ASIN
|
||||
and download them to the same folder.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
{ebook.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Preferred Format
|
||||
</label>
|
||||
<select
|
||||
value={ebook.preferredFormat || 'epub'}
|
||||
onChange={(e) => updateEbook('preferredFormat', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="epub">EPUB</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="mobi">MOBI</option>
|
||||
<option value="azw3">AZW3</option>
|
||||
<option value="any">Any format</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
EPUB is recommended for most e-readers. "Any format" will download the first available format.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Base URL (Advanced) */}
|
||||
{ebook.enabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Base URL (Advanced)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.baseUrl || 'https://annas-archive.li'}
|
||||
onChange={(e) => updateEbook('baseUrl', e.target.value)}
|
||||
placeholder="https://annas-archive.li"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Change this if the primary Anna's Archive mirror is unavailable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FlareSolverr (Optional - for Cloudflare bypass) */}
|
||||
{ebook.enabled && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
FlareSolverr URL (Optional)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={ebook.flaresolverrUrl || ''}
|
||||
onChange={(e) => updateEbook('flaresolverrUrl', e.target.value)}
|
||||
placeholder="http://localhost:8191"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={testFlaresolverrConnection}
|
||||
loading={testingFlaresolverr}
|
||||
variant="secondary"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
FlareSolverr helps bypass Cloudflare protection on Anna's Archive.
|
||||
Leave empty if not needed.
|
||||
</p>
|
||||
{flaresolverrTestResult && (
|
||||
<div
|
||||
className={`mt-2 p-3 rounded-lg text-sm ${
|
||||
flaresolverrTestResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'
|
||||
}`}
|
||||
>
|
||||
{flaresolverrTestResult.success ? '✓ ' : '✗ '}
|
||||
{flaresolverrTestResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!ebook.flaresolverrUrl && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>Note:</strong> Without FlareSolverr, e-book downloads may fail if Anna's Archive
|
||||
has Cloudflare protection enabled. Success rates are typically lower without it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
How it works
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>• Searches Anna's Archive in two ways:</li>
|
||||
<li className="ml-4">1. First tries ASIN (exact match - most accurate)</li>
|
||||
<li className="ml-4">2. Falls back to title + author (with book/language filters)</li>
|
||||
<li>• Downloads matching e-book in your preferred format</li>
|
||||
<li>• Places e-book file in the same folder as the audiobook</li>
|
||||
<li>• If no match is found or download fails, audiobook download continues normally</li>
|
||||
<li>• Completely optional and non-blocking</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Warning Box */}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-yellow-900 dark:text-yellow-100 mb-2">
|
||||
⚠️ Important Note
|
||||
</h3>
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
Anna's Archive is a shadow library. Use of this feature is at your own discretion and responsibility.
|
||||
Ensure compliance with your local laws and regulations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
loading={saving}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Save E-book Sidecar Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { EbookTab } from './EbookTab';
|
||||
export { useEbookSettings } from './useEbookSettings';
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Component: E-book Settings Tab - Custom Hook
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { EbookSettings, TestResult } from '../../lib/types';
|
||||
|
||||
interface UseEbookSettingsProps {
|
||||
ebook: EbookSettings;
|
||||
onChange: (ebook: EbookSettings) => void;
|
||||
onSuccess: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
markAsSaved: () => void;
|
||||
}
|
||||
|
||||
export function useEbookSettings({ ebook, onChange, onSuccess, onError, markAsSaved }: UseEbookSettingsProps) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingFlaresolverr, setTestingFlaresolverr] = useState(false);
|
||||
const [flaresolverrTestResult, setFlaresolverrTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
/**
|
||||
* Update a single ebook field
|
||||
*/
|
||||
const updateEbook = (field: keyof EbookSettings, value: string | boolean) => {
|
||||
onChange({ ...ebook, [field]: value });
|
||||
if (field === 'flaresolverrUrl') {
|
||||
setFlaresolverrTestResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Test FlareSolverr connection
|
||||
*/
|
||||
const testFlaresolverrConnection = async () => {
|
||||
if (!ebook.flaresolverrUrl) {
|
||||
setFlaresolverrTestResult({
|
||||
success: false,
|
||||
message: 'Please enter a FlareSolverr URL first',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTestingFlaresolverr(true);
|
||||
setFlaresolverrTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/settings/ebook/test-flaresolverr', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: ebook.flaresolverrUrl }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
setFlaresolverrTestResult(result);
|
||||
} catch (error) {
|
||||
setFlaresolverrTestResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Test failed',
|
||||
});
|
||||
} finally {
|
||||
setTestingFlaresolverr(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save e-book settings to API
|
||||
*/
|
||||
const saveSettings = async () => {
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/settings/ebook', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: ebook.enabled || false,
|
||||
format: ebook.preferredFormat || 'epub',
|
||||
baseUrl: ebook.baseUrl || 'https://annas-archive.li',
|
||||
flaresolverrUrl: ebook.flaresolverrUrl || '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save e-book settings');
|
||||
}
|
||||
|
||||
onSuccess('E-book sidecar settings saved successfully!');
|
||||
markAsSaved();
|
||||
setTimeout(() => onSuccess(''), 3000);
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : 'Failed to save e-book settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
saving,
|
||||
testingFlaresolverr,
|
||||
flaresolverrTestResult,
|
||||
updateEbook,
|
||||
testFlaresolverrConnection,
|
||||
saveSettings,
|
||||
};
|
||||
}
|
||||
+49
-38
@@ -5,55 +5,51 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { IndexerManagement } from '@/components/admin/indexers/IndexerManagement';
|
||||
import { FlagConfigRow } from '@/components/admin/FlagConfigRow';
|
||||
import { IndexerFlagConfig } from '@/lib/utils/ranking-algorithm';
|
||||
|
||||
interface SavedIndexerConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
priority: number;
|
||||
seedingTimeMinutes: number;
|
||||
rssEnabled: boolean;
|
||||
categories: number[];
|
||||
}
|
||||
import { useIndexersSettings } from './useIndexersSettings';
|
||||
import type { Settings, SavedIndexerConfig } from '../../lib/types';
|
||||
|
||||
interface IndexersTabProps {
|
||||
settings: {
|
||||
prowlarr: {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
};
|
||||
};
|
||||
originalSettings?: {
|
||||
prowlarr: {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
};
|
||||
} | null;
|
||||
settings: Settings;
|
||||
indexers: SavedIndexerConfig[];
|
||||
flagConfigs: IndexerFlagConfig[];
|
||||
onSettingsChange: (settings: any) => void;
|
||||
onChange: (settings: Settings) => void;
|
||||
onIndexersChange: (indexers: SavedIndexerConfig[]) => void;
|
||||
onFlagConfigsChange: (configs: IndexerFlagConfig[]) => void;
|
||||
onValidationChange: (validated: any) => void;
|
||||
validated: { prowlarr?: boolean };
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
onRefreshIndexers?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function IndexersTab({
|
||||
settings,
|
||||
originalSettings,
|
||||
indexers,
|
||||
flagConfigs,
|
||||
onSettingsChange,
|
||||
onChange,
|
||||
onIndexersChange,
|
||||
onFlagConfigsChange,
|
||||
onValidationChange,
|
||||
validated,
|
||||
onRefreshIndexers,
|
||||
}: IndexersTabProps) {
|
||||
const { testing, testResult, testConnection } = useIndexersSettings({
|
||||
prowlarrUrl: settings.prowlarr.url,
|
||||
prowlarrApiKey: settings.prowlarr.apiKey,
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
});
|
||||
|
||||
// Auto-load indexers when component mounts if prowlarr is configured
|
||||
useEffect(() => {
|
||||
if (settings.prowlarr.url && settings.prowlarr.apiKey && onRefreshIndexers) {
|
||||
onRefreshIndexers();
|
||||
}
|
||||
// Only run on mount, not when settings change
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
@@ -73,14 +69,11 @@ export function IndexersTab({
|
||||
type="url"
|
||||
value={settings.prowlarr.url}
|
||||
onChange={(e) => {
|
||||
onSettingsChange({
|
||||
onChange({
|
||||
...settings,
|
||||
prowlarr: { ...settings.prowlarr, url: e.target.value },
|
||||
});
|
||||
// Only invalidate if URL actually changed from original
|
||||
if (originalSettings && e.target.value !== originalSettings.prowlarr.url) {
|
||||
onValidationChange({ ...validated, prowlarr: false });
|
||||
}
|
||||
onValidationChange(false);
|
||||
}}
|
||||
placeholder="http://localhost:9696"
|
||||
/>
|
||||
@@ -94,14 +87,11 @@ export function IndexersTab({
|
||||
type="password"
|
||||
value={settings.prowlarr.apiKey}
|
||||
onChange={(e) => {
|
||||
onSettingsChange({
|
||||
onChange({
|
||||
...settings,
|
||||
prowlarr: { ...settings.prowlarr, apiKey: e.target.value },
|
||||
});
|
||||
// Only invalidate if API key actually changed from original
|
||||
if (originalSettings && e.target.value !== originalSettings.prowlarr.apiKey) {
|
||||
onValidationChange({ ...validated, prowlarr: false });
|
||||
}
|
||||
onValidationChange(false);
|
||||
}}
|
||||
placeholder="Enter API key"
|
||||
/>
|
||||
@@ -110,6 +100,27 @@ export function IndexersTab({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
disabled={!settings.prowlarr.url || !settings.prowlarr.apiKey}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<IndexerManagement
|
||||
prowlarrUrl={settings.prowlarr.url}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { IndexersTab } from './IndexersTab';
|
||||
export { useIndexersSettings } from './useIndexersSettings';
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Component: Indexers Settings Tab - Custom Hook
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import type { TestResult } from '../../lib/types';
|
||||
|
||||
interface UseIndexersSettingsProps {
|
||||
prowlarrUrl: string;
|
||||
prowlarrApiKey: string;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
onRefreshIndexers?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useIndexersSettings({
|
||||
prowlarrUrl,
|
||||
prowlarrApiKey,
|
||||
onValidationChange,
|
||||
onRefreshIndexers,
|
||||
}: UseIndexersSettingsProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
/**
|
||||
* Test Prowlarr connection
|
||||
*/
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/settings/test-prowlarr', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: prowlarrUrl,
|
||||
apiKey: prowlarrApiKey,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
onValidationChange(true);
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to Prowlarr. Found ${data.indexers?.length || 0} indexers`,
|
||||
});
|
||||
|
||||
// Refresh indexers from database if callback provided
|
||||
if (onRefreshIndexers) {
|
||||
await onRefreshIndexers();
|
||||
}
|
||||
} else {
|
||||
onValidationChange(false);
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: data.error || 'Connection failed',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
onValidationChange(false);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to test connection';
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: errorMsg,
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
testing,
|
||||
testResult,
|
||||
testConnection,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Component: Audiobookshelf Library Settings Section
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Settings, ABSLibrary } from '../../lib/types';
|
||||
|
||||
interface AudiobookshelfSectionProps {
|
||||
settings: Settings;
|
||||
onChange: (settings: Settings) => void;
|
||||
onValidationChange: (section: string, isValid: boolean) => void;
|
||||
libraries: ABSLibrary[];
|
||||
testing: boolean;
|
||||
testResult: { success: boolean; message: string } | null;
|
||||
onTestConnection: () => void;
|
||||
}
|
||||
|
||||
export function AudiobookshelfSection({
|
||||
settings,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
libraries,
|
||||
testing,
|
||||
testResult,
|
||||
onTestConnection,
|
||||
}: AudiobookshelfSectionProps) {
|
||||
const handleServerUrlChange = (serverUrl: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
audiobookshelf: { ...settings.audiobookshelf, serverUrl },
|
||||
});
|
||||
onValidationChange('audiobookshelf', false);
|
||||
};
|
||||
|
||||
const handleApiTokenChange = (apiToken: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
audiobookshelf: { ...settings.audiobookshelf, apiToken },
|
||||
});
|
||||
onValidationChange('audiobookshelf', false);
|
||||
};
|
||||
|
||||
const handleLibraryChange = (libraryId: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
audiobookshelf: { ...settings.audiobookshelf, libraryId },
|
||||
});
|
||||
onValidationChange('audiobookshelf', false);
|
||||
};
|
||||
|
||||
const handleTriggerScanChange = (triggerScanAfterImport: boolean) => {
|
||||
onChange({
|
||||
...settings,
|
||||
audiobookshelf: { ...settings.audiobookshelf, triggerScanAfterImport },
|
||||
});
|
||||
};
|
||||
|
||||
const handleAudibleRegionChange = (audibleRegion: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
audibleRegion,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Audiobookshelf Server
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure your Audiobookshelf server connection and audiobook library.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Server URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={settings.audiobookshelf.serverUrl}
|
||||
onChange={(e) => handleServerUrlChange(e.target.value)}
|
||||
placeholder="http://localhost:13378"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
API Token
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.audiobookshelf.apiToken}
|
||||
onChange={(e) => handleApiTokenChange(e.target.value)}
|
||||
placeholder="Enter your Audiobookshelf API token"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Generate in Audiobookshelf: Settings → API Keys → Add API Key
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Audiobook Library
|
||||
</label>
|
||||
{libraries.length > 0 ? (
|
||||
<select
|
||||
value={settings.audiobookshelf.libraryId}
|
||||
onChange={(e) => handleLibraryChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Select a library...</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 py-2">
|
||||
Test your connection to load libraries.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.audiobookshelf.triggerScanAfterImport}
|
||||
onChange={(e) => handleTriggerScanChange(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Trigger library scan after import
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Automatically triggers Audiobookshelf to scan its filesystem after organizing downloaded files.
|
||||
Only enable this if you have Audiobookshelf's filesystem watcher (automatic scanning) disabled.
|
||||
Most users should leave this disabled and rely on Audiobookshelf's built-in automatic detection.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Audible Region Selection */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-2">
|
||||
<label
|
||||
htmlFor="audible-region-abs"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Audible Region
|
||||
</label>
|
||||
<select
|
||||
id="audible-region-abs"
|
||||
value={settings.audibleRegion || 'us'}
|
||||
onChange={(e) => handleAudibleRegionChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="us">United States</option>
|
||||
<option value="ca">Canada</option>
|
||||
<option value="uk">United Kingdom</option>
|
||||
<option value="au">Australia</option>
|
||||
<option value="in">India</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
|
||||
configuration in Audiobookshelf. This ensures accurate book matching and metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={onTestConnection}
|
||||
loading={testing}
|
||||
disabled={!settings.audiobookshelf.serverUrl || !settings.audiobookshelf.apiToken}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Component: Library Settings Tab (Main)
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { Settings } from '../../lib/types';
|
||||
import { useLibrarySettings } from './useLibrarySettings';
|
||||
import { PlexSection } from './PlexSection';
|
||||
import { AudiobookshelfSection } from './AudiobookshelfSection';
|
||||
|
||||
interface LibraryTabProps {
|
||||
settings: Settings;
|
||||
onChange: (settings: Settings) => void;
|
||||
onValidationChange: (section: string, isValid: boolean) => void;
|
||||
onSuccess: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export function LibraryTab({
|
||||
settings,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: LibraryTabProps) {
|
||||
const {
|
||||
plexLibraries,
|
||||
testingPlex,
|
||||
plexTestResult,
|
||||
testPlexConnection,
|
||||
absLibraries,
|
||||
testingAbs,
|
||||
absTestResult,
|
||||
testABSConnection,
|
||||
} = useLibrarySettings(onSuccess, onError, onValidationChange);
|
||||
|
||||
const handleTestPlexConnection = () => {
|
||||
testPlexConnection(settings.plex.url, settings.plex.token);
|
||||
};
|
||||
|
||||
const handleTestABSConnection = () => {
|
||||
testABSConnection(settings.audiobookshelf.serverUrl, settings.audiobookshelf.apiToken);
|
||||
};
|
||||
|
||||
// Render appropriate section based on backend mode
|
||||
if (settings.backendMode === 'plex') {
|
||||
return (
|
||||
<PlexSection
|
||||
settings={settings}
|
||||
onChange={onChange}
|
||||
onValidationChange={onValidationChange}
|
||||
libraries={plexLibraries}
|
||||
testing={testingPlex}
|
||||
testResult={plexTestResult}
|
||||
onTestConnection={handleTestPlexConnection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (settings.backendMode === 'audiobookshelf') {
|
||||
return (
|
||||
<AudiobookshelfSection
|
||||
settings={settings}
|
||||
onChange={onChange}
|
||||
onValidationChange={onValidationChange}
|
||||
libraries={absLibraries}
|
||||
testing={testingAbs}
|
||||
testResult={absTestResult}
|
||||
onTestConnection={handleTestABSConnection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen)
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Invalid backend mode. Please configure your backend in setup.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Component: Plex Library Settings Section
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Settings, PlexLibrary } from '../../lib/types';
|
||||
|
||||
interface PlexSectionProps {
|
||||
settings: Settings;
|
||||
onChange: (settings: Settings) => void;
|
||||
onValidationChange: (section: string, isValid: boolean) => void;
|
||||
libraries: PlexLibrary[];
|
||||
testing: boolean;
|
||||
testResult: { success: boolean; message: string } | null;
|
||||
onTestConnection: () => void;
|
||||
}
|
||||
|
||||
export function PlexSection({
|
||||
settings,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
libraries,
|
||||
testing,
|
||||
testResult,
|
||||
onTestConnection,
|
||||
}: PlexSectionProps) {
|
||||
const handleUrlChange = (url: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
plex: { ...settings.plex, url },
|
||||
});
|
||||
onValidationChange('plex', false);
|
||||
};
|
||||
|
||||
const handleTokenChange = (token: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
plex: { ...settings.plex, token },
|
||||
});
|
||||
onValidationChange('plex', false);
|
||||
};
|
||||
|
||||
const handleLibraryChange = (libraryId: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
plex: { ...settings.plex, libraryId },
|
||||
});
|
||||
onValidationChange('plex', false);
|
||||
};
|
||||
|
||||
const handleTriggerScanChange = (triggerScanAfterImport: boolean) => {
|
||||
onChange({
|
||||
...settings,
|
||||
plex: { ...settings.plex, triggerScanAfterImport },
|
||||
});
|
||||
};
|
||||
|
||||
const handleAudibleRegionChange = (audibleRegion: string) => {
|
||||
onChange({
|
||||
...settings,
|
||||
audibleRegion,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Plex Media Server
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure your Plex server connection and audiobook library.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Server URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={settings.plex.url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder="http://localhost:32400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Authentication Token
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={settings.plex.token}
|
||||
onChange={(e) => handleTokenChange(e.target.value)}
|
||||
placeholder="Enter your Plex token"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Find your token in Plex settings → Network → Show Advanced
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Audiobook Library
|
||||
</label>
|
||||
{libraries.length > 0 ? (
|
||||
<select
|
||||
value={settings.plex.libraryId}
|
||||
onChange={(e) => handleLibraryChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<option value="">Select a library...</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.title} ({lib.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 py-2">
|
||||
Test your connection to load libraries.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.plex.triggerScanAfterImport}
|
||||
onChange={(e) => handleTriggerScanChange(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Trigger library scan after import
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Automatically triggers Plex to scan its filesystem after organizing downloaded files.
|
||||
Only enable this if you have Plex's filesystem watcher (automatic scanning) disabled.
|
||||
Most users should leave this disabled and rely on Plex's built-in automatic detection.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Audible Region Selection */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6 space-y-2">
|
||||
<label
|
||||
htmlFor="audible-region"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Audible Region
|
||||
</label>
|
||||
<select
|
||||
id="audible-region"
|
||||
value={settings.audibleRegion || 'us'}
|
||||
onChange={(e) => handleAudibleRegionChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="us">United States</option>
|
||||
<option value="ca">Canada</option>
|
||||
<option value="uk">United Kingdom</option>
|
||||
<option value="au">Australia</option>
|
||||
<option value="in">India</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Select the Audible region that matches your metadata engine (Audnexus/Audible Agent)
|
||||
configuration in Plex. This ensures accurate book matching and metadata.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={onTestConnection}
|
||||
loading={testing}
|
||||
disabled={!settings.plex.url || !settings.plex.token}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Component: Library Tab Exports
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
export { LibraryTab } from './LibraryTab';
|
||||
export { useLibrarySettings } from './useLibrarySettings';
|
||||
export { PlexSection } from './PlexSection';
|
||||
export { AudiobookshelfSection } from './AudiobookshelfSection';
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Component: Library Settings Hook
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { PlexLibrary, ABSLibrary } from '../../lib/types';
|
||||
|
||||
interface UseLibrarySettingsReturn {
|
||||
// Plex state
|
||||
plexLibraries: PlexLibrary[];
|
||||
setPlexLibraries: (libraries: PlexLibrary[]) => void;
|
||||
testingPlex: boolean;
|
||||
plexTestResult: { success: boolean; message: string } | null;
|
||||
testPlexConnection: (url: string, token: string) => Promise<boolean>;
|
||||
|
||||
// ABS state
|
||||
absLibraries: ABSLibrary[];
|
||||
setAbsLibraries: (libraries: ABSLibrary[]) => void;
|
||||
testingAbs: boolean;
|
||||
absTestResult: { success: boolean; message: string } | null;
|
||||
testABSConnection: (serverUrl: string, apiToken: string) => Promise<boolean>;
|
||||
|
||||
// Shared state
|
||||
loadingLibraries: boolean;
|
||||
}
|
||||
|
||||
export function useLibrarySettings(
|
||||
onSuccess: (message: string) => void,
|
||||
onError: (message: string) => void,
|
||||
onValidationChange: (section: string, isValid: boolean) => void
|
||||
): UseLibrarySettingsReturn {
|
||||
// Plex state
|
||||
const [plexLibraries, setPlexLibraries] = useState<PlexLibrary[]>([]);
|
||||
const [testingPlex, setTestingPlex] = useState(false);
|
||||
const [plexTestResult, setPlexTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// ABS state
|
||||
const [absLibraries, setAbsLibraries] = useState<ABSLibrary[]>([]);
|
||||
const [testingAbs, setTestingAbs] = useState(false);
|
||||
const [absTestResult, setAbsTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
// Shared state
|
||||
const [loadingLibraries, setLoadingLibraries] = useState(false);
|
||||
|
||||
const testPlexConnection = useCallback(async (url: string, token: string): Promise<boolean> => {
|
||||
setTestingPlex(true);
|
||||
setPlexTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/admin/settings/test-plex', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, token }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setPlexTestResult({ success: true, message: `Connected to ${data.serverName}` });
|
||||
onSuccess(`Connected to ${data.serverName}. You can now save.`);
|
||||
onValidationChange('plex', true);
|
||||
|
||||
// Update libraries
|
||||
if (data.libraries) {
|
||||
setPlexLibraries(data.libraries);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
const errorMsg = data.error || 'Connection failed';
|
||||
setPlexTestResult({ success: false, message: errorMsg });
|
||||
onError(errorMsg);
|
||||
onValidationChange('plex', false);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to test connection';
|
||||
setPlexTestResult({ success: false, message: errorMsg });
|
||||
onError(errorMsg);
|
||||
onValidationChange('plex', false);
|
||||
return false;
|
||||
} finally {
|
||||
setTestingPlex(false);
|
||||
}
|
||||
}, [onSuccess, onError, onValidationChange]);
|
||||
|
||||
const testABSConnection = useCallback(async (serverUrl: string, apiToken: string): Promise<boolean> => {
|
||||
setTestingAbs(true);
|
||||
setAbsTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/api/setup/test-abs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl, apiToken }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setAbsTestResult({ success: true, message: 'Connected to Audiobookshelf' });
|
||||
onSuccess('Connected to Audiobookshelf. You can now save.');
|
||||
onValidationChange('audiobookshelf', true);
|
||||
|
||||
// Update libraries
|
||||
if (data.libraries) {
|
||||
setAbsLibraries(data.libraries);
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
const errorMsg = data.error || 'Connection failed';
|
||||
setAbsTestResult({ success: false, message: errorMsg });
|
||||
onError(errorMsg);
|
||||
onValidationChange('audiobookshelf', false);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to test connection';
|
||||
setAbsTestResult({ success: false, message: errorMsg });
|
||||
onError(errorMsg);
|
||||
onValidationChange('audiobookshelf', false);
|
||||
return false;
|
||||
} finally {
|
||||
setTestingAbs(false);
|
||||
}
|
||||
}, [onSuccess, onError, onValidationChange]);
|
||||
|
||||
return {
|
||||
plexLibraries,
|
||||
setPlexLibraries,
|
||||
testingPlex,
|
||||
plexTestResult,
|
||||
testPlexConnection,
|
||||
|
||||
absLibraries,
|
||||
setAbsLibraries,
|
||||
testingAbs,
|
||||
absTestResult,
|
||||
testABSConnection,
|
||||
|
||||
loadingLibraries,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Component: Paths Settings Tab
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { usePathsSettings } from './usePathsSettings';
|
||||
import type { PathsSettings } from '../../lib/types';
|
||||
|
||||
interface PathsTabProps {
|
||||
paths: PathsSettings;
|
||||
onChange: (paths: PathsSettings) => void;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
}
|
||||
|
||||
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
|
||||
const { testing, testResult, updatePath, testPaths } = usePathsSettings({
|
||||
paths,
|
||||
onChange,
|
||||
onValidationChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
Directory Paths
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure download and media directory paths.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Download Directory */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Download Directory
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.downloadDir}
|
||||
onChange={(e) => updatePath('downloadDir', e.target.value)}
|
||||
placeholder="/downloads"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Temporary location for torrent downloads (kept for seeding)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Media Directory */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Media Directory
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.mediaDir}
|
||||
onChange={(e) => updatePath('mediaDir', e.target.value)}
|
||||
placeholder="/media/audiobooks"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Final location for organized audiobook library (Your backend scans this directory)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Metadata Tagging Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="metadata-tagging-settings"
|
||||
checked={paths.metadataTaggingEnabled}
|
||||
onChange={(e) => updatePath('metadataTaggingEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="metadata-tagging-settings"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Auto-tag audio files with metadata
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Automatically write correct title, author, and narrator metadata to m4b and mp3 files
|
||||
during file organization. This significantly improves Plex matching accuracy for audiobooks
|
||||
with missing or incorrect metadata.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chapter Merging Toggle */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="chapter-merging-settings"
|
||||
checked={paths.chapterMergingEnabled}
|
||||
onChange={(e) => updatePath('chapterMergingEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="chapter-merging-settings"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Auto-merge chapters to M4B
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Automatically merge multi-file chapter downloads into a single M4B audiobook with chapter
|
||||
markers. Improves playback experience and library organization.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Paths Button */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={testPaths}
|
||||
loading={testing}
|
||||
disabled={!paths.downloadDir || !paths.mediaDir}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
Test Paths
|
||||
</Button>
|
||||
{testResult && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-800 dark:text-green-200'
|
||||
: 'bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PathsTab } from './PathsTab';
|
||||
export { usePathsSettings } from './usePathsSettings';
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Component: Paths Settings Tab - Custom Hook
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { PathsSettings, TestResult } from '../../lib/types';
|
||||
|
||||
interface UsePathsSettingsProps {
|
||||
paths: PathsSettings;
|
||||
onChange: (paths: PathsSettings) => void;
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
}
|
||||
|
||||
export function usePathsSettings({ paths, onChange, onValidationChange }: UsePathsSettingsProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
|
||||
/**
|
||||
* Update a single path field
|
||||
*/
|
||||
const updatePath = (field: keyof PathsSettings, value: string | boolean) => {
|
||||
onChange({ ...paths, [field]: value });
|
||||
onValidationChange(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Test if paths are valid and writable
|
||||
*/
|
||||
const testPaths = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/setup/test-paths', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
downloadDir: paths.downloadDir,
|
||||
mediaDir: paths.mediaDir,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const result: TestResult = {
|
||||
success: true,
|
||||
message: 'All paths are valid and writable'
|
||||
};
|
||||
setTestResult(result);
|
||||
onValidationChange(true);
|
||||
return result;
|
||||
} else {
|
||||
const result: TestResult = {
|
||||
success: false,
|
||||
message: data.error || 'Path validation failed'
|
||||
};
|
||||
setTestResult(result);
|
||||
onValidationChange(false);
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
const result: TestResult = {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to test paths'
|
||||
};
|
||||
setTestResult(result);
|
||||
onValidationChange(false);
|
||||
return result;
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
testing,
|
||||
testResult,
|
||||
updatePath,
|
||||
testPaths,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user