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,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 (
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-6 text-lg font-semibold text-foreground">
|
||||||
|
{t('smtp.title')}
|
||||||
|
</h1>
|
||||||
|
<SmtpSettingsForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<SmtpConfig | null> {
|
||||||
|
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<SmtpConfig> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -130,7 +130,12 @@
|
|||||||
"fromAddress": "Absenderadresse (Von)",
|
"fromAddress": "Absenderadresse (Von)",
|
||||||
"fromAddressHelp": "Die E-Mail-Adresse, die als Absender angezeigt wird.",
|
"fromAddressHelp": "Die E-Mail-Adresse, die als Absender angezeigt wird.",
|
||||||
"save": "Einstellungen speichern",
|
"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": {
|
"widgets": {
|
||||||
|
|||||||
@@ -130,7 +130,12 @@
|
|||||||
"fromAddress": "Sender Address (From)",
|
"fromAddress": "Sender Address (From)",
|
||||||
"fromAddressHelp": "The email address shown as the sender.",
|
"fromAddressHelp": "The email address shown as the sender.",
|
||||||
"save": "Save Settings",
|
"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": {
|
"widgets": {
|
||||||
|
|||||||
Reference in New Issue
Block a user