feat(07-06): settings-api client + SmtpSettingsForm + SMTP page (DKV-05)
- settings-api.ts: fetchSmtp/saveSmtp/testSmtp with credentials:'include'; SmtpConfig exposes only hasPassword (T-07-17) - smtp-settings-form.tsx: Surface C form with show/hide password toggle, test-feedback auto-clears 6s - settings/general/smtp/page.tsx: SmtpSettingsPage heading + SmtpSettingsForm - de.json + en.json: add smtp.showPassword/hidePassword/testTesting/testSuccess/testFailed keys - TDD GREEN: 5/5 tests pass; aria-hidden* on required markers removed for correct getByLabelText matching
This commit is contained in:
@@ -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<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) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user