4de87a8ea2
Server reachable without credentials (port 25 open relay) returns
{ success: true, warning: 'no_auth' } instead of green success.
Frontend shows red warning: server reachable but emails will fail.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
344 lines
10 KiB
TypeScript
344 lines
10 KiB
TypeScript
'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<FormState>(DEFAULT_FORM);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isTesting, setIsTesting] = useState(false);
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [testFeedback, setTestFeedback] = useState<TestFeedback>(null);
|
|
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | 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 && result.warning === 'no_auth') {
|
|
setTestFeedback({ type: 'error', message: t('smtp.testNoAuth') });
|
|
} else 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 (
|
|
<form onSubmit={handleSave} className="space-y-4">
|
|
{/* Host */}
|
|
<div>
|
|
<label htmlFor="smtp-host" className={labelClass}>
|
|
{t('smtp.host')}
|
|
</label>
|
|
<input
|
|
id="smtp-host"
|
|
type="text"
|
|
required
|
|
placeholder="smtp.example.com"
|
|
className={inputClass}
|
|
value={form.host}
|
|
onChange={(e) =>
|
|
setForm((prev) => ({ ...prev, host: e.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Port */}
|
|
<div>
|
|
<label htmlFor="smtp-port" className={labelClass}>
|
|
{t('smtp.port')}
|
|
</label>
|
|
<input
|
|
id="smtp-port"
|
|
type="number"
|
|
required
|
|
min={1}
|
|
max={65535}
|
|
className={inputClass}
|
|
value={form.port}
|
|
onChange={(e) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
port: Number(e.target.value) || 587,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Verschlüsselung */}
|
|
<div>
|
|
<label htmlFor="smtp-encryption" className={labelClass}>
|
|
{t('smtp.encryption')}
|
|
</label>
|
|
<select
|
|
id="smtp-encryption"
|
|
required
|
|
className={inputClass}
|
|
value={form.encryption}
|
|
onChange={(e) =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
encryption: e.target.value as FormState['encryption'],
|
|
}))
|
|
}
|
|
>
|
|
{ENCRYPTION_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Benutzername */}
|
|
<div>
|
|
<label htmlFor="smtp-username" className={labelClass}>
|
|
{t('smtp.username')}
|
|
</label>
|
|
<input
|
|
id="smtp-username"
|
|
type="text"
|
|
className={inputClass}
|
|
value={form.username}
|
|
onChange={(e) =>
|
|
setForm((prev) => ({ ...prev, username: e.target.value }))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Passwort + show/hide toggle */}
|
|
<div>
|
|
<label htmlFor="smtp-password" className={labelClass}>
|
|
{t('smtp.password')}
|
|
</label>
|
|
<div className="relative flex max-w-md">
|
|
<input
|
|
id="smtp-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}
|
|
onChange={(e) =>
|
|
setForm((prev) => ({ ...prev, password: e.target.value }))
|
|
}
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label={
|
|
showPassword ? t('smtp.hidePassword') : t('smtp.showPassword')
|
|
}
|
|
onClick={() => setShowPassword((prev) => !prev)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showPassword ? (
|
|
/* eye-off icon */
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
|
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
|
<line x1="1" y1="1" x2="23" y2="23" />
|
|
</svg>
|
|
) : (
|
|
/* eye icon */
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Absenderadresse */}
|
|
<div>
|
|
<label htmlFor="smtp-from-address" className={labelClass}>
|
|
{t('smtp.fromAddress')}
|
|
</label>
|
|
<input
|
|
id="smtp-from-address"
|
|
type="email"
|
|
required
|
|
placeholder="tessera@example.com"
|
|
className={inputClass}
|
|
value={form.fromAddress}
|
|
onChange={(e) =>
|
|
setForm((prev) => ({ ...prev, fromAddress: e.target.value }))
|
|
}
|
|
/>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t('smtp.fromAddressHelp')}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Action row */}
|
|
<div className="flex gap-3 pt-2">
|
|
<button
|
|
type="submit"
|
|
disabled={isSaving}
|
|
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{t('smtp.save')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={isTesting}
|
|
onClick={handleTest}
|
|
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{t('smtp.test')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Connection test feedback */}
|
|
{testFeedback && (
|
|
<p
|
|
className={`text-sm ${
|
|
testFeedback.type === 'success'
|
|
? ''
|
|
: testFeedback.type === 'error'
|
|
? 'text-destructive'
|
|
: 'text-muted-foreground'
|
|
}`}
|
|
style={
|
|
testFeedback.type === 'success'
|
|
? { color: 'oklch(0.40 0.15 148)' }
|
|
: undefined
|
|
}
|
|
>
|
|
{testFeedback.message}
|
|
</p>
|
|
)}
|
|
</form>
|
|
);
|
|
}
|