Files
tessera-ctl/apps/web/src/components/settings/smtp-settings-form.tsx
T
schalli 4de87a8ea2
Tessera CI/CD / Lint & Type Check (push) Waiting to run
Tessera CI/CD / Tests (push) Blocked by required conditions
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
fix(07): SMTP test warns when connection passes but no auth configured
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>
2026-06-28 01:29:53 +02:00

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>
);
}