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 */} {/* Right-side cluster: settings gear + "Jetzt abrufen" trigger */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* {/*
* Einstellungen-Link. Absolute literal href (NOT the dynamic * Zahnrad-Link. Absolute literal href (NOT the dynamic
* [category]/[moduleSlug] basePath): the tender-radar settings page * [category]/[moduleSlug] basePath): tender-radar routes only
* only exists as the literal route /modules/tender-radar/settings — * exist as literal paths — the dynamic settings route returns
* the dynamic settings route returns "Modul nicht gefunden" for this * "Modul nicht gefunden" for this module, so the gear must point
* module, so the gear must point at the literal path directly. * 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 <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" 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')} title={t('page.mySourcesTitle')}
aria-label={t('page.settingsTitle')} 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"> <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" /> <circle cx="12" cy="12" r="3" />
@@ -1,89 +1,83 @@
'use client'; 'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { import Link from 'next/link';
fetchNotificationPref, import { useAuthStore } from '@/lib/stores/auth-store';
saveNotificationPref,
type NotificationPref,
} from '@/lib/tender-radar-api';
import { EmailAlertConfigForm } from './components/EmailAlertConfigForm';
import { RssFeedListForm } from './components/RssFeedListForm'; import { RssFeedListForm } from './components/RssFeedListForm';
import { SourceConfigForm } from './components/SourceConfigForm'; 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 * Through Plan 02 this page carried four sections spanning three different
* Quelle im Admin-Bereich konfigurierbar") — the frontend half of the * responsibilities: platform administration (poll interval, RSS feeds),
* requirement, driving the Plan 05 GET/PUT /modules/tender-radar/source-config * tenant/user administration (mailbox), and a purely personal setting
* endpoint via SourceConfigForm. * (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 * - `SourceConfigForm` (D-03): the DÖE poll interval. There is exactly one
* below: a Täglich/Wöchentlich/Aus selector for this user's digest interval * public source, polling it per-user would just fetch the same data
* preference, loaded via fetchNotificationPref on mount and saved via * multiple times.
* saveNotificationPref on change. Kept inline here (the page is already a * - `RssFeedListForm scope="platform"` (D-02): admin-managed feeds that
* 'use client' shell, unlike the admin-only SourceConfigForm split) rather * apply to every user, e.g. the service.bund.de default seeded since
* than split into a separate component — this is a single select, no * Phase 14. Personal feeds live on "Meine Quellen" instead and never
* standalone unit-test coverage was called for in this plan. * 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 * Role check (the original backlog trigger, `2026-08-11-tender-radar-
* SourceConfigForm: RssFeedListForm, the admin CRUD list for the GLOBAL * einstellungen-mischen-rollen.md`, open point 5): this check is DISPLAY
* (not per-tenant) RSS feed sources. Extends this existing settings page * ONLY. It hides the two admin sections from a normal user so they never
* rather than building a new one (D-09). * 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" * No module-loader whitelist change needed here — nested route under the
* section: EmailAlertConfigForm, the PER-TENANT portal-alert mailbox * already-whitelisted `tender-radar` module page (Plan 10-02).
* 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).
* *
* Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the
* tenderRadar i18n namespace. * tenderRadar i18n namespace.
*/ */
export default function TenderRadarSettingsPage() { export default function TenderRadarSettingsPage() {
const t = useTranslations('tenderRadar'); const t = useTranslations('tenderRadar');
const [digestInterval, setDigestInterval] = useState< const user = useAuthStore((s) => s.user);
NotificationPref['digestInterval'] const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
>('daily');
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => { if (user === null) {
fetchNotificationPref() return (
.then((pref) => setDigestInterval(pref.digestInterval)) <div className="mx-auto max-w-2xl p-6">
.catch((err) => { <div className="mb-6 h-8 w-64 rounded bg-muted animate-pulse" />
setError( <div className="h-40 rounded bg-muted animate-pulse" />
err instanceof Error ? err.message : t('settings.errorLoad'), </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 ( return (
<div className="mx-auto max-w-2xl p-6"> <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"> <h2 className="text-lg font-semibold text-foreground mb-4">
{t('settings.rssSectionTitle')} {t('settings.rssSectionTitle')}
</h2> </h2>
<p className="mb-4 text-xs text-muted-foreground"> <p className="mb-1 text-xs text-muted-foreground">
{t('settings.rssSectionBody')} {t('settings.rssSectionBody')}
</p> </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"> <p className="mb-4 text-xs text-muted-foreground">
{t('settings.emailSectionBody')} {t('settings.rssSectionUserNote')}
</p> </p>
<EmailAlertConfigForm /> <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.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>}
</div> </div>
</div> </div>
); );
+3 -3
View File
@@ -681,6 +681,7 @@
"title": "Ausschreibungs-Radar", "title": "Ausschreibungs-Radar",
"loading": "Lädt…", "loading": "Lädt…",
"settingsTitle": "Einstellungen", "settingsTitle": "Einstellungen",
"mySourcesTitle": "Meine Quellen",
"pollNow": "Jetzt abrufen", "pollNow": "Jetzt abrufen",
"pollNowTitle": "Fällige Quellen jetzt abrufen", "pollNowTitle": "Fällige Quellen jetzt abrufen",
"polling": "Wird abgerufen…", "polling": "Wird abgerufen…",
@@ -857,9 +858,8 @@
"title": "Ausschreibungs-Radar — Einstellungen", "title": "Ausschreibungs-Radar — Einstellungen",
"rssSectionTitle": "RSS-Feeds", "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.", "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", "rssSectionUserNote": "Persönliche Feeds legen Sie auf „Meine Quellen\" an.",
"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.", "accessDeniedText": "Diese Seite verwaltet plattformweite Einstellungen und ist Administratoren vorbehalten.",
"notificationsSectionTitle": "Benachrichtigungen",
"digestIntervalLabel": "Digest-Intervall", "digestIntervalLabel": "Digest-Intervall",
"digestDaily": "Täglich", "digestDaily": "Täglich",
"digestWeekly": "Wöchentlich", "digestWeekly": "Wöchentlich",
+3 -3
View File
@@ -681,6 +681,7 @@
"title": "Tender Radar", "title": "Tender Radar",
"loading": "Loading…", "loading": "Loading…",
"settingsTitle": "Settings", "settingsTitle": "Settings",
"mySourcesTitle": "My sources",
"pollNow": "Fetch now", "pollNow": "Fetch now",
"pollNowTitle": "Fetch due sources now", "pollNowTitle": "Fetch due sources now",
"polling": "Fetching…", "polling": "Fetching…",
@@ -857,9 +858,8 @@
"title": "Tender Radar — Settings", "title": "Tender Radar — Settings",
"rssSectionTitle": "RSS Feeds", "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.", "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", "rssSectionUserNote": "Add personal feeds under \"My sources\".",
"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.", "accessDeniedText": "This page manages platform-wide settings and is reserved for administrators.",
"notificationsSectionTitle": "Notifications",
"digestIntervalLabel": "Digest Interval", "digestIntervalLabel": "Digest Interval",
"digestDaily": "Daily", "digestDaily": "Daily",
"digestWeekly": "Weekly", "digestWeekly": "Weekly",