feat(17-03): Administrationsseite schrumpft auf das, was Administration ist

- settings/page.tsx zeigt nur noch Abrufintervall + plattformweite Feeds;
  Postfach- und Benachrichtigungsabschnitt entfernt (ziehen auf my-sources um)
- Anzeigepruefung der Rolle aus dem Anmelde-Speicher: unbekannt -> Platzhalter,
  ADMIN/SUPER_ADMIN -> Inhalt, sonst Hinweistext + Verweis auf "Meine Quellen"
  (verbindliche Pruefung bleibt serverseitig, T-17-08/@UseModule, siehe
  Dateikommentar)
- Zahnrad auf der Modulseite fuehrt jetzt nach /my-sources statt /settings;
  neuer Schluessel page.mySourcesTitle, alter page.settingsTitle bleibt als
  Verweistext auf der Nutzerseite (Task 1) in Gebrauch
- settings.emailSectionTitle/emailSectionBody/notificationsSectionTitle aus
  beiden Sprachdateien entfernt (gegengeprueft: nirgends mehr referenziert);
  neue Schluessel settings.accessDeniedText, settings.rssSectionUserNote
This commit is contained in:
2026-08-12 11:58:29 +02:00
parent 4100bb575e
commit 150046e14f
4 changed files with 84 additions and 131 deletions
@@ -68,17 +68,23 @@ function TenderRadarContent() {
{/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
<div className="flex items-center gap-2">
{/*
* Einstellungen-Link. Absolute literal href (NOT the dynamic
* [category]/[moduleSlug] basePath): the tender-radar settings page
* only exists as the literal route /modules/tender-radar/settings —
* the dynamic settings route returns "Modul nicht gefunden" for this
* module, so the gear must point at the literal path directly.
* Zahnrad-Link. Absolute literal href (NOT the dynamic
* [category]/[moduleSlug] basePath): tender-radar routes only
* exist as literal paths — the dynamic settings route returns
* "Modul nicht gefunden" for this module, so the gear must point
* at the literal path directly.
*
* Phase 17 Plan 03 (D-03): points at "Meine Quellen", not the
* admin-only settings page — that's the page every module user
* (not just administrators) is allowed to configure something
* on; administrators reach the platform-wide settings from there
* with one more click.
*/}
<Link
href="/modules/tender-radar/settings"
href="/modules/tender-radar/my-sources"
className="rounded border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t('page.settingsTitle')}
aria-label={t('page.settingsTitle')}
title={t('page.mySourcesTitle')}
aria-label={t('page.mySourcesTitle')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
@@ -1,89 +1,83 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
fetchNotificationPref,
saveNotificationPref,
type NotificationPref,
} from '@/lib/tender-radar-api';
import { EmailAlertConfigForm } from './components/EmailAlertConfigForm';
import Link from 'next/link';
import { useAuthStore } from '@/lib/stores/auth-store';
import { RssFeedListForm } from './components/RssFeedListForm';
import { SourceConfigForm } from './components/SourceConfigForm';
/**
* Ausschreibungs-Radar module settings page.
* Ausschreibungs-Radar module settings page — ADMINISTRATION ONLY (Phase
* 17, Plan 03, D-03).
*
* Delivers the admin-facing configuration UI for INGEST-06 ("Intervall pro
* Quelle im Admin-Bereich konfigurierbar") — the frontend half of the
* requirement, driving the Plan 05 GET/PUT /modules/tender-radar/source-config
* endpoint via SourceConfigForm.
* Through Plan 02 this page carried four sections spanning three different
* responsibilities: platform administration (poll interval, RSS feeds),
* tenant/user administration (mailbox), and a purely personal setting
* (digest interval) that had no business being here at all. Plan 01/02
* moved the mailbox and RSS feeds to per-user ownership; this plan finishes
* the split by moving what actually needs a person, not an admin — mailbox,
* digest interval — onto `/modules/tender-radar/my-sources`, leaving only
* what stays genuinely platform-wide:
*
* Plan 12-04 (NOTIFY-01, D-01/D-03) adds a "Benachrichtigungen" section
* below: a Täglich/Wöchentlich/Aus selector for this user's digest interval
* preference, loaded via fetchNotificationPref on mount and saved via
* saveNotificationPref on change. Kept inline here (the page is already a
* 'use client' shell, unlike the admin-only SourceConfigForm split) rather
* than split into a separate component — this is a single select, no
* standalone unit-test coverage was called for in this plan.
* - `SourceConfigForm` (D-03): the DÖE poll interval. There is exactly one
* public source, polling it per-user would just fetch the same data
* multiple times.
* - `RssFeedListForm scope="platform"` (D-02): admin-managed feeds that
* apply to every user, e.g. the service.bund.de default seeded since
* Phase 14. Personal feeds live on "Meine Quellen" instead and never
* appear here — the server only returns platform-wide feeds for this
* scope.
*
* 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).
* Role check (the original backlog trigger, `2026-08-11-tender-radar-
* einstellungen-mischen-rollen.md`, open point 5): this check is DISPLAY
* ONLY. It hides the two admin sections from a normal user so they never
* see a save button wired to something they can't do. The binding check
* lives on the server regardless of what this page renders: `source-config`
* and `rss-feeds` are `@UseModule('tender-radar')`-gated (Plan 17-02), and
* `POST /rss-feeds` with `scope: 'platform'` re-checks ADMIN/SUPER_ADMIN
* inline on the server (T-17-08). A manipulated frontend gains nothing.
* While the role is still unknown (`user === null`), a loading placeholder
* is shown instead of either state, so the admin sections never flash
* briefly for a normal user (T-17-16).
*
* Plan 14-03 (INGEST-05, D-06/D-07/D-09/D-13) adds an "E-Mail-Alerts"
* section: EmailAlertConfigForm, the PER-TENANT portal-alert mailbox
* config (separate from RSS's global feed list and DKV's own, separate
* invoice mailbox, D-03). Same "extend, don't rebuild" stance as RSS-Feeds.
*
* 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).
* No module-loader whitelist change needed here — nested route under the
* already-whitelisted `tender-radar` module page (Plan 10-02).
*
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* tenderRadar i18n namespace.
*/
export default function TenderRadarSettingsPage() {
const t = useTranslations('tenderRadar');
const [digestInterval, setDigestInterval] = useState<
NotificationPref['digestInterval']
>('daily');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
useEffect(() => {
fetchNotificationPref()
.then((pref) => setDigestInterval(pref.digestInterval))
.catch((err) => {
setError(
err instanceof Error ? err.message : t('settings.errorLoad'),
if (user === null) {
return (
<div className="mx-auto max-w-2xl p-6">
<div className="mb-6 h-8 w-64 rounded bg-muted animate-pulse" />
<div className="h-40 rounded bg-muted animate-pulse" />
</div>
);
}
if (!isAdmin) {
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="text-2xl font-semibold tracking-tight mb-4">
{t('settings.title')}
</h1>
<p className="mb-2 text-sm text-muted-foreground">
{t('settings.accessDeniedText')}
</p>
<Link
href="/modules/tender-radar/my-sources"
className="text-sm text-primary hover:underline"
>
{t('mySources.title')} &rarr;
</Link>
</div>
);
})
.finally(() => setIsLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleChange = async (value: string) => {
const next = value as NotificationPref['digestInterval'];
setDigestInterval(next);
setError(null);
setSaveSuccess(false);
setIsSaving(true);
try {
const result = await saveNotificationPref(next);
setDigestInterval(result.digestInterval);
setSaveSuccess(true);
} catch (err) {
setError(
err instanceof Error ? err.message : t('settings.errorSave'),
);
} finally {
setIsSaving(false);
}
};
return (
<div className="mx-auto max-w-2xl p-6">
@@ -96,60 +90,13 @@ export default function TenderRadarSettingsPage() {
<h2 className="text-lg font-semibold text-foreground mb-4">
{t('settings.rssSectionTitle')}
</h2>
<p className="mb-4 text-xs text-muted-foreground">
<p className="mb-1 text-xs text-muted-foreground">
{t('settings.rssSectionBody')}
</p>
<RssFeedListForm scope="platform" />
</div>
<div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
{t('settings.emailSectionTitle')}
</h2>
<p className="mb-4 text-xs text-muted-foreground">
{t('settings.emailSectionBody')}
{t('settings.rssSectionUserNote')}
</p>
<EmailAlertConfigForm />
</div>
<div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
{t('settings.notificationsSectionTitle')}
</h2>
{isLoading ? (
<div className="h-9 max-w-xs rounded bg-muted animate-pulse" />
) : (
<div>
<label
htmlFor="tr-digest-interval"
className="mb-1 block text-sm text-foreground"
>
{t('settings.digestIntervalLabel')}
</label>
<select
id="tr-digest-interval"
value={digestInterval}
onChange={(e) => handleChange(e.target.value)}
disabled={isSaving}
className="h-9 max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
>
<option value="daily">{t('settings.digestDaily')}</option>
<option value="weekly">{t('settings.digestWeekly')}</option>
<option value="off">{t('settings.digestOff')}</option>
</select>
<p className="mt-1 text-xs text-muted-foreground">
{t('settings.digestHelp')}
</p>
</div>
)}
{saveSuccess && (
<p className="mt-2 text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
{t('settings.saveSuccess')}
</p>
)}
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
<RssFeedListForm scope="platform" />
</div>
</div>
);
+3 -3
View File
@@ -681,6 +681,7 @@
"title": "Ausschreibungs-Radar",
"loading": "Lädt…",
"settingsTitle": "Einstellungen",
"mySourcesTitle": "Meine Quellen",
"pollNow": "Jetzt abrufen",
"pollNowTitle": "Fällige Quellen jetzt abrufen",
"polling": "Wird abgerufen…",
@@ -857,9 +858,8 @@
"title": "Ausschreibungs-Radar — Einstellungen",
"rssSectionTitle": "RSS-Feeds",
"rssSectionBody": "Öffentliche, plattformweite RSS-Quellen (z. B. service.bund.de oder eine subreport-elvis-Kommunalfeed) — gilt für alle Mandanten gleich, nicht pro Mandant konfigurierbar.",
"emailSectionTitle": "E-Mail-Alerts",
"emailSectionBody": "Postfach, in das Vergabeportale Ihre Benachrichtigungsmails schicken — pro Mandant konfigurierbar, getrennt vom DKV-Rechnungspostfach. Aus diesen Mails erkannte Ausschreibungen sind nur für Ihren Mandanten sichtbar.",
"notificationsSectionTitle": "Benachrichtigungen",
"rssSectionUserNote": "Persönliche Feeds legen Sie auf „Meine Quellen\" an.",
"accessDeniedText": "Diese Seite verwaltet plattformweite Einstellungen und ist Administratoren vorbehalten.",
"digestIntervalLabel": "Digest-Intervall",
"digestDaily": "Täglich",
"digestWeekly": "Wöchentlich",
+3 -3
View File
@@ -681,6 +681,7 @@
"title": "Tender Radar",
"loading": "Loading…",
"settingsTitle": "Settings",
"mySourcesTitle": "My sources",
"pollNow": "Fetch now",
"pollNowTitle": "Fetch due sources now",
"polling": "Fetching…",
@@ -857,9 +858,8 @@
"title": "Tender Radar — Settings",
"rssSectionTitle": "RSS Feeds",
"rssSectionBody": "Public, platform-wide RSS sources (e.g. service.bund.de or a subreport-elvis municipal feed) — applies equally to all tenants, not configurable per tenant.",
"emailSectionTitle": "Email Alerts",
"emailSectionBody": "Mailbox that tender portals send their notification emails to — configurable per tenant, separate from the DKV invoice mailbox. Tenders detected from these emails are only visible to your tenant.",
"notificationsSectionTitle": "Notifications",
"rssSectionUserNote": "Add personal feeds under \"My sources\".",
"accessDeniedText": "This page manages platform-wide settings and is reserved for administrators.",
"digestIntervalLabel": "Digest Interval",
"digestDaily": "Daily",
"digestWeekly": "Weekly",