mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add extensible notification providers + UI/API
Introduce a provider-based notification system and wire it through the API and admin UI. Added INotificationProvider + notification service implementation and providers (apprise, discord, ntfy, pushover), plus a GET /api/admin/notifications/providers endpoint to expose provider metadata. Refactored code to use provider type strings (removed enum coupling), updated masking/encryption calls, and simplified the test notification endpoint to accept backendId or type+config and call sendToBackend directly. UI: NotificationsTab now fetches provider metadata and renders provider cards and dynamic config forms (fields driven by provider metadata). Added config field rendering, improved backend cards, and edit/delete actions. APIs: New providers route, updated admin notification CRUD routes to validate provider types dynamically, updated test route schema. Added download-client categories POST API to fetch categories from clients and wired postImportCategory handling in download-client routes. Other notable changes: BookDate now fetches Claude models dynamically from Anthropic's Models API; added paginated model fetch helper. Added ALLOW_WEAK_PASSWORD flag exposure to auth providers and password change logic. Doc updates and various tests added/updated. File-organization doc clarifies EPERM fix using stream-based copy.
This commit is contained in:
@@ -10,7 +10,7 @@ import { Modal } from '@/components/ui/Modal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { fetchWithAuth } from '@/lib/utils/api';
|
||||
import { DownloadClientType, getClientDisplayName } from '@/lib/interfaces/download-client.interface';
|
||||
import { DownloadClientType, getClientDisplayName, CLIENT_PROTOCOL_MAP } from '@/lib/interfaces/download-client.interface';
|
||||
|
||||
interface DownloadClientModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -31,6 +31,7 @@ interface DownloadClientModalProps {
|
||||
localPath?: string;
|
||||
category?: string;
|
||||
customPath?: string;
|
||||
postImportCategory?: string;
|
||||
};
|
||||
onSave: (client: any) => Promise<void>;
|
||||
apiMode: 'wizard' | 'settings';
|
||||
@@ -62,6 +63,9 @@ export function DownloadClientModal({
|
||||
const [localPath, setLocalPath] = useState('');
|
||||
const [category, setCategory] = useState('readmeabook');
|
||||
const [customPath, setCustomPath] = useState('');
|
||||
const [postImportCategory, setPostImportCategory] = useState('');
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
const [fetchingCategories, setFetchingCategories] = useState(false);
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -85,6 +89,7 @@ export function DownloadClientModal({
|
||||
setLocalPath(initialClient.localPath || '');
|
||||
setCategory(initialClient.category || 'readmeabook');
|
||||
setCustomPath(initialClient.customPath || '');
|
||||
setPostImportCategory(initialClient.postImportCategory || '');
|
||||
} else {
|
||||
// Add mode defaults
|
||||
setName(typeName);
|
||||
@@ -98,9 +103,12 @@ export function DownloadClientModal({
|
||||
setLocalPath('');
|
||||
setCategory('readmeabook');
|
||||
setCustomPath('');
|
||||
setPostImportCategory('');
|
||||
}
|
||||
setTestResult(null);
|
||||
setErrors({});
|
||||
setAvailableCategories([]);
|
||||
setFetchingCategories(false);
|
||||
}
|
||||
}, [isOpen, mode, initialClient, type]);
|
||||
|
||||
@@ -137,6 +145,50 @@ export function DownloadClientModal({
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setFetchingCategories(true);
|
||||
try {
|
||||
const isPasswordMasked = password === '********';
|
||||
const categoryData = {
|
||||
type,
|
||||
name,
|
||||
url,
|
||||
username: username || undefined,
|
||||
password: isPasswordMasked ? undefined : password,
|
||||
...(mode === 'edit' && initialClient && isPasswordMasked ? { clientId: initialClient.id } : {}),
|
||||
disableSSLVerify,
|
||||
remotePathMappingEnabled,
|
||||
remotePath: remotePathMappingEnabled ? remotePath : undefined,
|
||||
localPath: remotePathMappingEnabled ? localPath : undefined,
|
||||
};
|
||||
|
||||
const endpoint = apiMode === 'wizard'
|
||||
? '/api/setup/download-client-categories'
|
||||
: '/api/admin/settings/download-clients/categories';
|
||||
|
||||
const response = apiMode === 'wizard'
|
||||
? await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(categoryData),
|
||||
})
|
||||
: await fetchWithAuth(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(categoryData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (response.ok && data.success) {
|
||||
setAvailableCategories(data.categories || []);
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — categories are optional
|
||||
} finally {
|
||||
setFetchingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!validate()) {
|
||||
return;
|
||||
@@ -187,6 +239,11 @@ export function DownloadClientModal({
|
||||
// Handle both endpoint response formats (settings returns message, wizard returns version)
|
||||
const message = data.message || (data.version ? `Connected successfully (v${data.version})` : 'Connection successful');
|
||||
setTestResult({ success: true, message });
|
||||
|
||||
// Fetch categories for torrent clients after successful connection
|
||||
if (type && CLIENT_PROTOCOL_MAP[type] === 'torrent') {
|
||||
fetchCategories();
|
||||
}
|
||||
} else {
|
||||
setTestResult({ success: false, message: data.error || 'Connection test failed' });
|
||||
}
|
||||
@@ -230,6 +287,7 @@ export function DownloadClientModal({
|
||||
localPath: remotePathMappingEnabled ? localPath : undefined,
|
||||
category,
|
||||
customPath: sanitizedCustomPath || undefined,
|
||||
postImportCategory,
|
||||
};
|
||||
|
||||
if (mode === 'edit' && initialClient) {
|
||||
@@ -384,6 +442,37 @@ export function DownloadClientModal({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Post-Import Category (torrent clients only) */}
|
||||
{type && CLIENT_PROTOCOL_MAP[type] === 'torrent' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Post-Import Category
|
||||
</label>
|
||||
{type === 'qbittorrent' && availableCategories.length > 0 ? (
|
||||
<select
|
||||
value={postImportCategory}
|
||||
onChange={(e) => setPostImportCategory(e.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">None (keep original)</option>
|
||||
{availableCategories.map((cat) => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
value={postImportCategory}
|
||||
onChange={(e) => setPostImportCategory(e.target.value)}
|
||||
placeholder="e.g. completed"
|
||||
disabled={fetchingCategories}
|
||||
/>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
After import, change the download's category/label in the client. Leave empty to skip.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-start mb-3">
|
||||
|
||||
Reference in New Issue
Block a user