+ );
+}
diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
index 28e692a..4138772 100644
--- a/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
+++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx
@@ -6,6 +6,7 @@ import {
saveNotificationPref,
type NotificationPref,
} from '@/lib/tender-radar-api';
+import { RssFeedListForm } from './components/RssFeedListForm';
import { SourceConfigForm } from './components/SourceConfigForm';
/**
@@ -24,6 +25,11 @@ import { SourceConfigForm } from './components/SourceConfigForm';
* than split into a separate component — this is a single select, no
* standalone unit-test coverage was called for in this plan.
*
+ * Plan 14-02 (INGEST-04, D-08/D-09/D-14) adds an "RSS-Feeds" section below
+ * SourceConfigForm: RssFeedListForm, the admin CRUD list for the GLOBAL
+ * (not per-tenant) RSS feed sources. Extends this existing settings page
+ * rather than building a new one (D-09).
+ *
* No module-loader whitelist change is needed here: this is a standard
* Next.js App Router route nested under the already-whitelisted
* `tender-radar` module page (Plan 10-02).
@@ -82,6 +88,18 @@ export default function TenderRadarSettingsPage() {
+
+
+ RSS-Feeds
+
+
+ Öffentliche, plattformweite RSS-Quellen (z. B. service.bund.de oder
+ eine subreport-elvis-Kommunalfeed) — gilt für alle Mandanten
+ gleich, nicht pro Mandant konfigurierbar.
+
+
+
+
Benachrichtigungen
diff --git a/apps/web/src/lib/tender-radar-api.ts b/apps/web/src/lib/tender-radar-api.ts
index b11b962..8dec559 100644
--- a/apps/web/src/lib/tender-radar-api.ts
+++ b/apps/web/src/lib/tender-radar-api.ts
@@ -353,3 +353,96 @@ export async function saveNotificationPref(
if (!res.ok) throw new Error('Failed to save notification preference');
return res.json();
}
+
+/**
+ * A single admin-managed, GLOBAL RSS feed source (Plan 14-02, D-08/D-14).
+ * Unlike every other resource in this file, this is NOT per-tenant/per-user
+ * data — the list is shared platform-wide, mirroring `SourceConfig`'s
+ * global stance.
+ */
+export interface RssFeedSource {
+ id: string;
+ url: string;
+ label: string;
+ isActive: boolean;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** Payload accepted by POST /modules/tender-radar/rss-feeds. */
+export interface CreateRssFeedPayload {
+ url: string;
+ label: string;
+ isActive?: boolean;
+}
+
+/**
+ * Extracts the backend's error message from a non-2xx JSON error body
+ * (Nest's default exception filter shape: `{ statusCode, message, error }`)
+ * so the save-time denylist/SSRF rejection (D-14, T-14-02-01) surfaces its
+ * specific reason inline instead of a generic "failed to save" string.
+ */
+async function extractErrorMessage(res: Response, fallback: string): Promise {
+ try {
+ const body = (await res.json()) as { message?: unknown };
+ if (typeof body.message === 'string' && body.message) return body.message;
+ if (Array.isArray(body.message) && body.message.length) {
+ return body.message.join(', ');
+ }
+ } catch {
+ /* body wasn't JSON — fall through to the generic message */
+ }
+ return fallback;
+}
+
+/**
+ * List every admin-managed RSS feed (global, D-08).
+ * GET /modules/tender-radar/rss-feeds
+ */
+export async function listRssFeeds(): Promise {
+ const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds`, {
+ credentials: 'include',
+ });
+ if (!res.ok) {
+ throw new Error(
+ await extractErrorMessage(res, 'Failed to fetch RSS feeds'),
+ );
+ }
+ return res.json();
+}
+
+/**
+ * Add a new global RSS feed URL. Rejected with the backend's specific
+ * hostname/SSRF-guard message (D-14) when the URL is denylisted/private/
+ * loopback — the rejection message is relayed as-is via `extractErrorMessage`
+ * so the admin sees WHY, not just that the save failed.
+ * POST /modules/tender-radar/rss-feeds
+ */
+export async function createRssFeed(
+ payload: CreateRssFeedPayload,
+): Promise {
+ const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) {
+ throw new Error(await extractErrorMessage(res, 'Failed to create RSS feed'));
+ }
+ return res.json();
+}
+
+/**
+ * Remove a global RSS feed.
+ * DELETE /modules/tender-radar/rss-feeds/:feedId
+ */
+export async function deleteRssFeed(id: string): Promise {
+ const res = await fetch(`${API_URL}/modules/tender-radar/rss-feeds/${id}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ });
+ if (!res.ok) {
+ throw new Error(await extractErrorMessage(res, 'Failed to delete RSS feed'));
+ }
+}