feat(14-03): add email-config admin routes + D-13 read-side visibility filter + EmailAlertConfigForm
buildTenderWhere gains an optional ownerTenantId param: a resolved
requesting tenant sees global tenders (null) plus its own private ones
(OR[global, mine]); an unresolved requester fails CLOSED to global-only —
never an accidental cross-tenant leak.
TendersController: listTenders/getTender resolve the requesting tenant
leniently from the auth context (resolveRequestingTenantId, never throws)
and apply the D-13 filter; getTender 404s (not a distinct "forbidden") when
a tender's non-null ownerTenantId doesn't match the requester, so no
cross-tenant detail leak. New GET/PUT /modules/tender-radar/email-config
routes (Roles ADMIN/SUPER_ADMIN, tenantId from auth context, never the
body) delegate to TenderEmailConfigService — declared before @Get(':id')
per the project's NestJS route-order convention.
Web: EmailAlertConfig type + fetchEmailConfig/saveEmailConfig client
functions; EmailAlertConfigForm mirrors the DKV InboxConfigForm (password
blank on load, only sent when typed — T-07-12), added as a new
"E-Mail-Alerts" section on the existing tender-radar settings page.
Hardcoded German strings — i18n is Plan 14-05.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+128
@@ -0,0 +1,128 @@
|
||||
import { cleanup, render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock @/lib/tender-radar-api
|
||||
const mockFetchEmailConfig = vi.fn();
|
||||
const mockSaveEmailConfig = vi.fn();
|
||||
|
||||
vi.mock('@/lib/tender-radar-api', () => ({
|
||||
fetchEmailConfig: (...args: unknown[]) => mockFetchEmailConfig(...args),
|
||||
saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...args),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockFetchEmailConfig.mockReset();
|
||||
mockSaveEmailConfig.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const EXISTING_CONFIG = {
|
||||
protocol: 'imap' as const,
|
||||
host: 'imap.example.test',
|
||||
port: 993,
|
||||
encryption: 'ssl-tls' as const,
|
||||
folder: 'INBOX',
|
||||
senderFilter: null,
|
||||
domain: null,
|
||||
isActive: true,
|
||||
username: 'alerts@example.test',
|
||||
hasPassword: true,
|
||||
};
|
||||
|
||||
describe('EmailAlertConfigForm', () => {
|
||||
it('shows the "not yet saved" banner and defaults when no config exists (fresh tenant)', async () => {
|
||||
mockFetchEmailConfig.mockResolvedValue(null);
|
||||
|
||||
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
|
||||
render(<EmailAlertConfigForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Noch nicht gespeichert/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(mockFetchEmailConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('loads an existing config and never pre-fills the password field (T-07-12)', async () => {
|
||||
mockFetchEmailConfig.mockResolvedValue(EXISTING_CONFIG);
|
||||
|
||||
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
|
||||
render(<EmailAlertConfigForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('imap.example.test')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByDisplayValue('alerts@example.test')).toBeInTheDocument();
|
||||
const passwordInput = screen.getByLabelText(/^Passwort$/i) as HTMLInputElement;
|
||||
expect(passwordInput.value).toBe('');
|
||||
expect(screen.queryByText(/Noch nicht gespeichert/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking "Speichern" calls saveEmailConfig WITHOUT a password field when none was typed', async () => {
|
||||
mockFetchEmailConfig.mockResolvedValue(null);
|
||||
mockSaveEmailConfig.mockResolvedValue({ ...EXISTING_CONFIG, hasPassword: false, username: null });
|
||||
|
||||
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
|
||||
render(<EmailAlertConfigForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch nicht gespeichert/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/IMAP-Server/i), {
|
||||
target: { value: 'imap.example.test' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Speichern/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSaveEmailConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const payload = mockSaveEmailConfig.mock.calls[0][0];
|
||||
expect(payload).not.toHaveProperty('password');
|
||||
expect(payload.host).toBe('imap.example.test');
|
||||
});
|
||||
|
||||
it('clicking "Speichern" includes the password field only when a new one was typed (T-07-12)', async () => {
|
||||
mockFetchEmailConfig.mockResolvedValue(EXISTING_CONFIG);
|
||||
mockSaveEmailConfig.mockResolvedValue(EXISTING_CONFIG);
|
||||
|
||||
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
|
||||
render(<EmailAlertConfigForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('imap.example.test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/^Passwort$/i), {
|
||||
target: { value: 'new-secret' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Speichern/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSaveEmailConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const payload = mockSaveEmailConfig.mock.calls[0][0];
|
||||
expect(payload.password).toBe('new-secret');
|
||||
});
|
||||
|
||||
it('switching protocol to exchange shows the EWS/domain fields and hides port/encryption', async () => {
|
||||
mockFetchEmailConfig.mockResolvedValue(null);
|
||||
|
||||
const { EmailAlertConfigForm } = await import('./EmailAlertConfigForm');
|
||||
render(<EmailAlertConfigForm />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Noch nicht gespeichert/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Protokoll/i), {
|
||||
target: { value: 'exchange' },
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/EWS-Endpunkt-URL/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Windows-Domäne/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/^Port \*$/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
type EmailAlertConfig,
|
||||
fetchEmailConfig,
|
||||
saveEmailConfig,
|
||||
} from '@/lib/tender-radar-api';
|
||||
|
||||
/** Inline eye SVG (16×16) for show/hide password toggle */
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ellipse cx="8" cy="8" rx="6" ry="4" />
|
||||
<circle cx="8" cy="8" r="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline eye-off SVG (16×16) for show/hide password toggle */
|
||||
function EyeOffIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="2" y1="2" x2="14" y2="14" />
|
||||
<path d="M6.5 5.2A6 6 0 0114 8a9 9 0 01-1.2 2M4.5 4.5A6 6 0 002 8a6 6 0 008 4.5" />
|
||||
<circle cx="8" cy="8" r="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Form state — includes the password field (optional, only sent when the
|
||||
* admin types a new one). T-07-12: password is never pre-filled from server
|
||||
* data; the server only returns hasPassword.
|
||||
*/
|
||||
interface FormState {
|
||||
protocol: 'imap' | 'exchange';
|
||||
host: string;
|
||||
port: string;
|
||||
encryption: 'none' | 'starttls' | 'ssl-tls';
|
||||
folder: string;
|
||||
senderFilter: string;
|
||||
username: string;
|
||||
password: string; // intentionally blank on load (T-07-12)
|
||||
domain: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: FormState = {
|
||||
protocol: 'imap',
|
||||
host: '',
|
||||
port: '993',
|
||||
encryption: 'ssl-tls',
|
||||
folder: 'INBOX',
|
||||
senderFilter: '',
|
||||
username: '',
|
||||
password: '', // blank — T-07-12
|
||||
domain: '',
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
function configToForm(config: EmailAlertConfig): FormState {
|
||||
return {
|
||||
protocol: config.protocol,
|
||||
host: config.host ?? '',
|
||||
port: config.port !== null && config.port !== undefined ? String(config.port) : '993',
|
||||
encryption: config.encryption,
|
||||
folder: config.folder,
|
||||
senderFilter: config.senderFilter ?? '',
|
||||
username: config.username ?? '',
|
||||
password: '', // NEVER pre-fill from server — T-07-12
|
||||
domain: config.domain ?? '',
|
||||
isActive: config.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
function formToPayload(
|
||||
form: FormState,
|
||||
): Partial<EmailAlertConfig> & { password?: string } {
|
||||
const payload: Partial<EmailAlertConfig> & { password?: string } = {
|
||||
protocol: form.protocol,
|
||||
host: form.host || undefined,
|
||||
port: form.port ? Number(form.port) : undefined,
|
||||
encryption: form.encryption,
|
||||
folder: form.folder,
|
||||
senderFilter: form.senderFilter || undefined,
|
||||
username: form.username || undefined,
|
||||
domain: form.domain || undefined,
|
||||
isActive: form.isActive,
|
||||
};
|
||||
// Only include password when the admin typed a new one (T-07-12)
|
||||
if (form.password) {
|
||||
payload.password = form.password;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* EmailAlertConfigForm — per-tenant portal-alert mailbox config (Plan
|
||||
* 14-03, INGEST-05/CONFIG-02, D-06/D-07/D-13). Mirrors the DKV Fleet
|
||||
* `InboxConfigForm` (protocol/host/port/encryption/folder/senderFilter/
|
||||
* domain/username/password/isActive) — password blank on load, only sent
|
||||
* when the admin types a new one (T-07-12).
|
||||
*
|
||||
* Deliberately does NOT include a "Test Connection" button or an
|
||||
* Abrufintervall field: this module's poll cadence is governed by the
|
||||
* shared, platform-wide 'email-alert' TenderSourcePollConfig (D-15,
|
||||
* pollGranularity='tick'), not a per-tenant setting, and no
|
||||
* test-connection endpoint exists for this config (out of this plan's
|
||||
* scope).
|
||||
*
|
||||
* Hardcoded German strings — full i18n is CONFIG-03 (Plan 14-05), matching
|
||||
* the same intentional convention already used by SourceConfigForm/
|
||||
* RssFeedListForm.
|
||||
*/
|
||||
export function EmailAlertConfigForm() {
|
||||
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
// Whether config exists in DB (null = not yet saved)
|
||||
const [configExists, setConfigExists] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmailConfig()
|
||||
.then((config) => {
|
||||
if (config) {
|
||||
setForm(configToForm(config));
|
||||
setConfigExists(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep defaults if fetch failed
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const update = useCallback(
|
||||
(key: keyof FormState, value: string | boolean) => {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
setSaveError(null);
|
||||
setSaveSuccess(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
setSaveSuccess(false);
|
||||
try {
|
||||
const result = await saveEmailConfig(formToPayload(form));
|
||||
// configToForm() always blanks the password field (T-07-12)
|
||||
setForm(configToForm(result));
|
||||
setSaveSuccess(true);
|
||||
setConfigExists(true);
|
||||
} catch (err) {
|
||||
setSaveError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Einstellungen konnten nicht gespeichert werden',
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputCls =
|
||||
'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground';
|
||||
const labelCls = 'mb-1 block text-sm text-foreground';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="mb-1 h-4 w-32 rounded bg-muted animate-pulse" />
|
||||
<div className="h-9 max-w-md rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!configExists && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
||||
Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und
|
||||
speichern.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Protokoll */}
|
||||
<div>
|
||||
<label htmlFor="email-alert-protocol" className={labelCls}>
|
||||
Protokoll *
|
||||
</label>
|
||||
<select
|
||||
id="email-alert-protocol"
|
||||
className={inputCls}
|
||||
value={form.protocol}
|
||||
onChange={(e) =>
|
||||
update('protocol', e.target.value as 'imap' | 'exchange')
|
||||
}
|
||||
>
|
||||
<option value="imap">IMAP</option>
|
||||
<option value="exchange">Exchange</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Host / EWS-URL */}
|
||||
<div>
|
||||
<label htmlFor="email-alert-host" className={labelCls}>
|
||||
{form.protocol === 'exchange' ? 'EWS-Endpunkt-URL' : 'IMAP-Server'} *
|
||||
</label>
|
||||
<input
|
||||
id="email-alert-host"
|
||||
type="text"
|
||||
required
|
||||
placeholder={
|
||||
form.protocol === 'exchange'
|
||||
? 'https://mail.firma.de/EWS/Exchange.asmx'
|
||||
: 'imap.firma.de'
|
||||
}
|
||||
className={inputCls}
|
||||
value={form.host}
|
||||
onChange={(e) => update('host', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* IMAP-only: Port */}
|
||||
{form.protocol === 'imap' && (
|
||||
<div>
|
||||
<label htmlFor="email-alert-port" className={labelCls}>
|
||||
Port *
|
||||
</label>
|
||||
<input
|
||||
id="email-alert-port"
|
||||
type="number"
|
||||
required
|
||||
className={inputCls}
|
||||
value={form.port}
|
||||
onChange={(e) => update('port', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IMAP-only: Verschlüsselung */}
|
||||
{form.protocol === 'imap' && (
|
||||
<div>
|
||||
<label htmlFor="email-alert-encryption" className={labelCls}>
|
||||
Verschlüsselung *
|
||||
</label>
|
||||
<select
|
||||
id="email-alert-encryption"
|
||||
className={inputCls}
|
||||
value={form.encryption}
|
||||
onChange={(e) =>
|
||||
update(
|
||||
'encryption',
|
||||
e.target.value as 'none' | 'starttls' | 'ssl-tls',
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="none">Keine</option>
|
||||
<option value="starttls">STARTTLS</option>
|
||||
<option value="ssl-tls">SSL-TLS</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ordner / Postfach */}
|
||||
<div>
|
||||
<label htmlFor="email-alert-folder" className={labelCls}>
|
||||
Ordner
|
||||
</label>
|
||||
<input
|
||||
id="email-alert-folder"
|
||||
type="text"
|
||||
placeholder={form.protocol === 'exchange' ? 'Inbox' : 'INBOX'}
|
||||
className={inputCls}
|
||||
value={form.folder}
|
||||
onChange={(e) => update('folder', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Exchange-only: Domain */}
|
||||
{form.protocol === 'exchange' && (
|
||||
<div>
|
||||
<label htmlFor="email-alert-domain" className={labelCls}>
|
||||
Windows-Domäne
|
||||
</label>
|
||||
<input
|
||||
id="email-alert-domain"
|
||||
type="text"
|
||||
placeholder="CONTOSO"
|
||||
className={inputCls}
|
||||
value={form.domain}
|
||||
onChange={(e) => update('domain', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Absenderfilter */}
|
||||
<div>
|
||||
<label htmlFor="email-alert-sender-filter" className={labelCls}>
|
||||
Absenderfilter
|
||||
</label>
|
||||
<input
|
||||
id="email-alert-sender-filter"
|
||||
type="email"
|
||||
placeholder="alerts@vergabeportal.de"
|
||||
className={inputCls}
|
||||
value={form.senderFilter}
|
||||
onChange={(e) => update('senderFilter', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Benutzername */}
|
||||
<div>
|
||||
<label htmlFor="email-alert-username" className={labelCls}>
|
||||
Benutzername
|
||||
</label>
|
||||
<input
|
||||
id="email-alert-username"
|
||||
type="text"
|
||||
className={inputCls}
|
||||
value={form.username}
|
||||
onChange={(e) => update('username', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Passwort — T-07-12: never pre-filled from server */}
|
||||
<div>
|
||||
<label htmlFor="email-alert-password" className={labelCls}>
|
||||
Passwort
|
||||
</label>
|
||||
<div className="relative max-w-md">
|
||||
<input
|
||||
id="email-alert-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 pr-10 text-sm text-foreground"
|
||||
value={form.password}
|
||||
placeholder="••••••••"
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aktiv — toggle switch */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
htmlFor="email-alert-active"
|
||||
className="text-sm text-foreground cursor-pointer"
|
||||
>
|
||||
Aktiv
|
||||
</label>
|
||||
<button
|
||||
id="email-alert-active"
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={form.isActive}
|
||||
onClick={() => update('isActive', !form.isActive)}
|
||||
className="relative flex-shrink-0 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 22,
|
||||
background: form.isActive ? 'var(--primary)' : 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 rounded-full bg-white shadow transition-transform"
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
left: 2,
|
||||
transform: form.isActive ? 'translateX(18px)' : 'translateX(0)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form actions */}
|
||||
<div className="flex gap-3 pt-4 border-t border-border mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSaving ? 'Wird gespeichert...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{saveSuccess && (
|
||||
<p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
|
||||
Einstellungen gespeichert.
|
||||
</p>
|
||||
)}
|
||||
{saveError && <p className="text-sm text-destructive">{saveError}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
saveNotificationPref,
|
||||
type NotificationPref,
|
||||
} from '@/lib/tender-radar-api';
|
||||
import { EmailAlertConfigForm } from './components/EmailAlertConfigForm';
|
||||
import { RssFeedListForm } from './components/RssFeedListForm';
|
||||
import { SourceConfigForm } from './components/SourceConfigForm';
|
||||
|
||||
@@ -30,6 +31,11 @@ import { SourceConfigForm } from './components/SourceConfigForm';
|
||||
* (not per-tenant) RSS feed sources. Extends this existing settings page
|
||||
* rather than building a new one (D-09).
|
||||
*
|
||||
* 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).
|
||||
@@ -100,6 +106,19 @@ export default function TenderRadarSettingsPage() {
|
||||
<RssFeedListForm />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
E-Mail-Alerts
|
||||
</h2>
|
||||
<p className="mb-4 text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<EmailAlertConfigForm />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
Benachrichtigungen
|
||||
|
||||
@@ -446,3 +446,68 @@ export async function deleteRssFeed(id: string): Promise<void> {
|
||||
throw new Error(await extractErrorMessage(res, 'Failed to delete RSS feed'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This tenant's portal-alert mailbox config (Plan 14-03, INGEST-05/
|
||||
* CONFIG-02, D-06/D-07). Unlike SourceConfig/RssFeedSource (platform-wide),
|
||||
* this is PER-TENANT — the backend derives tenantId from the auth cookie,
|
||||
* this client never sends a tenantId.
|
||||
*
|
||||
* Security (T-07-12): the password is NEVER returned — only `hasPassword`.
|
||||
* The password field is only sent in `saveEmailConfig`'s payload when the
|
||||
* admin has typed a new one (same InboxConfigForm/DKV convention).
|
||||
*/
|
||||
export interface EmailAlertConfig {
|
||||
protocol: 'imap' | 'exchange';
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
encryption: 'none' | 'starttls' | 'ssl-tls';
|
||||
folder: string;
|
||||
senderFilter?: string | null;
|
||||
domain?: string | null;
|
||||
isActive: boolean;
|
||||
username?: string | null;
|
||||
/** true if a password is stored server-side — never the actual secret */
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch this tenant's email-alert mailbox config. Returns null when no
|
||||
* config has been saved yet (fresh tenant — mirrors DkvConfig's fetchConfig
|
||||
* convention).
|
||||
* GET /modules/tender-radar/email-config
|
||||
*/
|
||||
export async function fetchEmailConfig(): Promise<EmailAlertConfig | null> {
|
||||
const res = await fetch(`${API_URL}/modules/tender-radar/email-config`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
await extractErrorMessage(res, 'Failed to fetch email-alert config'),
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save this tenant's email-alert mailbox config.
|
||||
* PUT /modules/tender-radar/email-config
|
||||
* Only include `password` in the payload when the admin typed a new one
|
||||
* (T-07-12 — blank-on-load convention).
|
||||
*/
|
||||
export async function saveEmailConfig(
|
||||
payload: Partial<EmailAlertConfig> & { password?: string },
|
||||
): Promise<EmailAlertConfig> {
|
||||
const res = await fetch(`${API_URL}/modules/tender-radar/email-config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
await extractErrorMessage(res, 'Failed to save email-alert config'),
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user