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:
kikootwo
2026-02-10 15:06:20 -05:00
parent 4a38dd3da8
commit af0eaceb98
73 changed files with 3421 additions and 866 deletions
@@ -63,17 +63,14 @@ export function IndexerManagement({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Sync with parent when configuredIndexers changes
// In settings mode, the parent fetches indexers asynchronously and passes them
// as initialIndexers after mount. This effect picks up that late-arriving data.
// Wizard mode doesn't need this — it initializes correctly via useState above.
useEffect(() => {
if (onIndexersChange) {
onIndexersChange(configuredIndexers);
if (mode === 'settings') {
setConfiguredIndexers(initialIndexers);
}
}, [configuredIndexers, onIndexersChange]);
// Sync with initialIndexers prop changes
useEffect(() => {
setConfiguredIndexers(initialIndexers);
}, [initialIndexers]);
}, [initialIndexers, mode]);
const fetchIndexers = async () => {
setLoading(true);
@@ -149,17 +146,16 @@ export function IndexerManagement({
};
const handleSave = (config: SavedIndexerConfig) => {
let updated: SavedIndexerConfig[];
if (modalState.mode === 'add') {
// Add new indexer
setConfiguredIndexers([...configuredIndexers, config]);
updated = [...configuredIndexers, config];
} else {
// Update existing indexer
setConfiguredIndexers(
configuredIndexers.map((idx) =>
idx.id === config.id ? config : idx
)
updated = configuredIndexers.map((idx) =>
idx.id === config.id ? config : idx
);
}
setConfiguredIndexers(updated);
onIndexersChange?.(updated);
};
const handleDelete = (id: number) => {
@@ -175,9 +171,9 @@ export function IndexerManagement({
const confirmDelete = () => {
if (deleteModalState.indexerId) {
setConfiguredIndexers(
configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId)
);
const updated = configuredIndexers.filter((idx) => idx.id !== deleteModalState.indexerId);
setConfiguredIndexers(updated);
onIndexersChange?.(updated);
}
};