diff --git a/apps/web/src/app/(portal)/settings/general/smtp/page.tsx b/apps/web/src/app/(portal)/settings/general/smtp/page.tsx new file mode 100644 index 0000000..67d8eb4 --- /dev/null +++ b/apps/web/src/app/(portal)/settings/general/smtp/page.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { SmtpSettingsForm } from '@/components/settings/smtp-settings-form'; + +/** + * SMTP settings page — /settings/general/smtp (Surface C, DKV-05). + * Sits inside the existing settings layout (SettingsSidebar wraps it). + * Heading follows the settings/dashboard/page.tsx pattern exactly. + */ +export default function SmtpSettingsPage() { + const t = useTranslations('settings'); + + return ( +
+

+ {t('smtp.title')} +

+ +
+ ); +} diff --git a/apps/web/src/components/settings/smtp-settings-form.tsx b/apps/web/src/components/settings/smtp-settings-form.tsx new file mode 100644 index 0000000..adda3ac --- /dev/null +++ b/apps/web/src/components/settings/smtp-settings-form.tsx @@ -0,0 +1,341 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { + fetchSmtp, + saveSmtp, + testSmtp, + type SaveSmtpPayload, +} from '@/lib/settings-api'; + +// Encryption options — technical terms, not translated +const ENCRYPTION_OPTIONS = [ + { value: 'none', label: 'Keine' }, + { value: 'starttls', label: 'STARTTLS' }, + { value: 'ssl-tls', label: 'SSL-TLS' }, +] as const; + +interface FormState { + host: string; + port: number; + encryption: 'none' | 'starttls' | 'ssl-tls'; + username: string; + password: string; // T-07-17: always starts blank; never pre-filled from server + fromAddress: string; +} + +const DEFAULT_FORM: FormState = { + host: '', + port: 587, + encryption: 'starttls', + username: '', + password: '', + fromAddress: '', +}; + +type TestFeedback = + | { type: 'loading'; message: string } + | { type: 'success'; message: string } + | { type: 'error'; message: string } + | null; + +/** + * SMTP configuration form for /settings/general/smtp (Surface C, DKV-05). + * + * Loads existing config via GET /settings/smtp on mount. + * Password is NEVER pre-filled (T-07-17) — only sent when the user types one. + * Connection test feedback auto-clears after 6 seconds. + */ +export function SmtpSettingsForm() { + const t = useTranslations('settings'); + + const [form, setForm] = useState(DEFAULT_FORM); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [testFeedback, setTestFeedback] = useState(null); + const clearTimerRef = useRef | null>(null); + + // Load existing config on mount + useEffect(() => { + fetchSmtp() + .then((config) => { + if (config) { + setForm({ + host: config.host, + port: config.port, + encryption: config.encryption, + username: config.username ?? '', + password: '', // T-07-17: never pre-filled + fromAddress: config.fromAddress, + }); + } + }) + .catch(() => { + // Silent fail — user can still enter config manually + }); + }, []); + + // Clear any pending auto-dismiss timer on unmount + useEffect(() => { + return () => { + if (clearTimerRef.current) clearTimeout(clearTimerRef.current); + }; + }, []); + + const buildPayload = (): SaveSmtpPayload => { + const payload: SaveSmtpPayload = { + host: form.host, + port: form.port, + encryption: form.encryption, + fromAddress: form.fromAddress, + }; + if (form.username.trim()) payload.username = form.username.trim(); + // T-07-17: only include password when user has typed a new one + if (form.password) payload.password = form.password; + return payload; + }; + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSaving(true); + try { + await saveSmtp(buildPayload()); + } catch { + // Silent fail for now — future: inline error state + } finally { + setIsSaving(false); + } + }; + + const handleTest = async () => { + if (clearTimerRef.current) clearTimeout(clearTimerRef.current); + setIsTesting(true); + setTestFeedback({ type: 'loading', message: t('smtp.testTesting') }); + try { + const result = await testSmtp(buildPayload()); + if (result.success) { + setTestFeedback({ type: 'success', message: t('smtp.testSuccess') }); + } else { + setTestFeedback({ type: 'error', message: t('smtp.testFailed') }); + } + } catch { + setTestFeedback({ type: 'error', message: t('smtp.testFailed') }); + } finally { + setIsTesting(false); + // Auto-clear after 6 seconds + clearTimerRef.current = setTimeout(() => setTestFeedback(null), 6000); + } + }; + + const inputClass = + 'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground'; + const labelClass = 'mb-1 block text-sm text-foreground'; + + return ( +
+ {/* Host */} +
+ + + setForm((prev) => ({ ...prev, host: e.target.value })) + } + /> +
+ + {/* Port */} +
+ + + setForm((prev) => ({ + ...prev, + port: Number(e.target.value) || 587, + })) + } + /> +
+ + {/* Verschlüsselung */} +
+ + +
+ + {/* Benutzername */} +
+ + + setForm((prev) => ({ ...prev, username: e.target.value })) + } + /> +
+ + {/* Passwort + show/hide toggle */} +
+ +
+ + setForm((prev) => ({ ...prev, password: e.target.value })) + } + /> + +
+
+ + {/* Absenderadresse */} +
+ + + setForm((prev) => ({ ...prev, fromAddress: e.target.value })) + } + /> +

+ {t('smtp.fromAddressHelp')} +

+
+ + {/* Action row */} +
+ + +
+ + {/* Connection test feedback */} + {testFeedback && ( +

+ {testFeedback.message} +

+ )} +
+ ); +} diff --git a/apps/web/src/lib/settings-api.ts b/apps/web/src/lib/settings-api.ts new file mode 100644 index 0000000..5056aa1 --- /dev/null +++ b/apps/web/src/lib/settings-api.ts @@ -0,0 +1,89 @@ +/** + * Settings API client — SMTP configuration. + * Consumes the SettingsController built in 07-03. + * All calls use credentials: 'include' for cookie-based auth. + * + * T-07-17: SmtpConfig exposes only hasPassword, never the secret. + * The password field is never pre-filled from server data. + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +// --- Types mirroring the 07-03 backend contract --- + +/** + * SMTP configuration as returned by GET /settings/smtp. + * encryptedPassword is NEVER returned (T-07-17). + */ +export interface SmtpConfig { + host: string; + port: number; + encryption: 'none' | 'starttls' | 'ssl-tls'; + username?: string; + fromAddress: string; + /** True when an encrypted password is stored; the password value is never exposed. */ + hasPassword: boolean; +} + +/** + * Payload for PUT /settings/smtp and POST /settings/smtp/test. + * password is optional — omit to preserve the existing stored password. + */ +export interface SaveSmtpPayload { + host: string; + port: number; + encryption: 'none' | 'starttls' | 'ssl-tls'; + username?: string; + /** Only include when the user has typed a new password. */ + password?: string; + fromAddress: string; +} + +// --- API functions --- + +/** + * Fetch the SMTP configuration for the current tenant. + * GET /settings/smtp + * Returns null when no configuration has been saved yet (404). + */ +export async function fetchSmtp(): Promise { + const res = await fetch(`${API_URL}/settings/smtp`, { + credentials: 'include', + }); + if (res.status === 404) return null; + if (!res.ok) throw new Error('Failed to fetch SMTP config'); + return res.json(); +} + +/** + * Save the SMTP configuration. + * PUT /settings/smtp + */ +export async function saveSmtp(payload: SaveSmtpPayload): Promise { + const res = await fetch(`${API_URL}/settings/smtp`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Failed to save SMTP config'); + return res.json(); +} + +/** + * Test the SMTP connection with the given settings. + * POST /settings/smtp/test + * Returns { success: boolean } — backend runs nodemailer.verify(). + */ +export async function testSmtp( + payload: SaveSmtpPayload, +): Promise<{ success: boolean }> { + const res = await fetch(`${API_URL}/settings/smtp/test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error('Failed to test SMTP config'); + return res.json(); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index ceac8e7..7bddf5a 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -130,7 +130,12 @@ "fromAddress": "Absenderadresse (Von)", "fromAddressHelp": "Die E-Mail-Adresse, die als Absender angezeigt wird.", "save": "Einstellungen speichern", - "test": "Verbindung testen" + "test": "Verbindung testen", + "showPassword": "Passwort anzeigen", + "hidePassword": "Passwort verbergen", + "testTesting": "Verbindung wird getestet...", + "testSuccess": "Verbindung erfolgreich", + "testFailed": "Verbindung fehlgeschlagen" } }, "widgets": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index db617de..f287b1a 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -130,7 +130,12 @@ "fromAddress": "Sender Address (From)", "fromAddressHelp": "The email address shown as the sender.", "save": "Save Settings", - "test": "Test Connection" + "test": "Test Connection", + "showPassword": "Show password", + "hidePassword": "Hide password", + "testTesting": "Testing connection...", + "testSuccess": "Connection successful", + "testFailed": "Connection failed" } }, "widgets": {