feat(07-05): settings page + InboxConfigForm — Surface B inbox tab (DKV-01)
Tessera CI/CD / Build & Deploy (push) Blocked by required conditions
Tessera CI/CD / Lint & Type Check (push) Successful in 37s
Tessera CI/CD / Tests (push) Waiting to run

- settings/page.tsx: tab bar (Posteingang / Fahrzeuge), Vehicles tab links to /vehicles
- InboxConfigForm: all UI-SPEC Tab-1 fields in order, show/hide password (T-07-12)
- Active toggle: role=switch, pill shape, primary/muted colors
- testConnection + saveConfig wired; inline green/destructive feedback
- Password never pre-filled from server (hasPassword only) per T-07-12
This commit is contained in:
2026-06-27 00:37:50 +02:00
parent 2fe7bca6e8
commit 45e9a3591f
2 changed files with 566 additions and 0 deletions
@@ -0,0 +1,510 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
type DkvConfig,
fetchConfig,
saveConfig,
testConnection,
} from '@/lib/dkv-api';
/** Inline eye SVG (16×16) for show/hide password toggle */
function EyeIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<ellipse cx="8" cy="8" rx="6" ry="4" />
<circle cx="8" cy="8" r="1.5" />
</svg>
);
}
/** Inline eye-off SVG (16×16) for show/hide password toggle */
function EyeOffIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="2" y1="2" x2="14" y2="14" />
<path d="M6.5 5.2A6 6 0 0114 8a9 9 0 01-1.2 2M4.5 4.5A6 6 0 002 8a6 6 0 008 4.5" />
<circle cx="8" cy="8" r="1.5" />
</svg>
);
}
/**
* Form state — includes the password field (optional, only sent when user types a new one).
* T-07-12: password is never pre-filled from server data; server only returns hasPassword.
*/
interface FormState {
protocol: 'imap' | 'exchange';
host: string;
port: string;
encryption: 'none' | 'starttls' | 'ssl-tls';
folder: string;
senderFilter: string;
pollIntervalMin: string;
username: string;
password: string; // intentionally blank on load (T-07-12)
exportRecipient: string;
vehicleFormatString: string;
isActive: boolean;
}
const DEFAULT_FORM: FormState = {
protocol: 'imap',
host: '',
port: '993',
encryption: 'ssl-tls',
folder: 'INBOX',
senderFilter: '',
pollIntervalMin: '30',
username: '',
password: '', // blank — T-07-12
exportRecipient: '',
vehicleFormatString: '{Marke}/{Modell}/{Kennzeichen}',
isActive: false,
};
function configToForm(config: DkvConfig): FormState {
return {
protocol: config.protocol,
host: config.host,
port: String(config.port),
encryption: config.encryption,
folder: config.folder,
senderFilter: config.senderFilter ?? '',
pollIntervalMin: String(config.pollIntervalMin),
username: config.username ?? '',
password: '', // NEVER pre-fill from server — T-07-12
exportRecipient: config.exportRecipient,
vehicleFormatString: config.vehicleFormatString,
isActive: config.isActive,
};
}
function formToPayload(
form: FormState,
): Partial<DkvConfig> & { password?: string } {
const payload: Partial<DkvConfig> & { password?: string } = {
protocol: form.protocol,
host: form.host,
port: Number(form.port),
encryption: form.encryption,
folder: form.folder,
senderFilter: form.senderFilter || undefined,
pollIntervalMin: Number(form.pollIntervalMin),
username: form.username || undefined,
exportRecipient: form.exportRecipient,
vehicleFormatString: form.vehicleFormatString,
isActive: form.isActive,
};
// Only include password when user typed a new one (T-07-12)
if (form.password) {
payload.password = form.password;
}
return payload;
}
/**
* InboxConfigForm — Surface B Tab 1: Posteingang configuration.
* Fields: Protokoll, Host, Port, Verschlüsselung, Ordner, Absenderfilter,
* Abrufintervall, Benutzername, Passwort (show/hide), Export-Empfänger,
* Fahrzeug-Formatstring, Aktiv toggle.
* Security: T-07-12 — password never pre-filled from server.
*/
export function InboxConfigForm() {
const t = useTranslations('dkvFleet');
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Test feedback state
const [testResult, setTestResult] = useState<{
success: boolean;
message?: string;
} | null>(null);
// Save feedback state
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
// Load config on mount
useEffect(() => {
fetchConfig()
.then((config) => {
setForm(configToForm(config));
})
.catch(() => {
// Keep defaults if config not yet created
})
.finally(() => {
setIsLoading(false);
});
}, []);
const update = useCallback(
(key: keyof FormState, value: string | boolean) => {
setForm((f) => ({ ...f, [key]: value }));
setTestResult(null);
setSaveError(null);
setSaveSuccess(false);
},
[],
);
const handleTestConnection = async () => {
setIsTesting(true);
setTestResult(null);
try {
const result = await testConnection(formToPayload(form));
setTestResult(result);
} catch (err) {
setTestResult({
success: false,
message: err instanceof Error ? err.message : 'Verbindung fehlgeschlagen',
});
} finally {
setIsTesting(false);
}
};
const handleSave = async () => {
setIsSaving(true);
setSaveError(null);
setSaveSuccess(false);
try {
await saveConfig(formToPayload(form));
setSaveSuccess(true);
// Clear password field after save (T-07-12)
setForm((f) => ({ ...f, password: '' }));
} catch (err) {
setSaveError(
err instanceof Error ? err.message : 'Einstellungen konnten nicht gespeichert werden',
);
} finally {
setIsSaving(false);
}
};
const inputCls =
'h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground';
const labelCls = 'mb-1 block text-sm text-foreground';
if (isLoading) {
return (
<div className="space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i}>
<div className="mb-1 h-4 w-32 rounded bg-muted animate-pulse" />
<div className="h-9 max-w-md rounded bg-muted animate-pulse" />
</div>
))}
</div>
);
}
return (
<div className="space-y-4">
{/* Protokoll */}
<div>
<label htmlFor="dkv-protocol" className={labelCls}>
{t('form.protocol')} *
</label>
<select
id="dkv-protocol"
className={inputCls}
value={form.protocol}
onChange={(e) =>
update('protocol', e.target.value as 'imap' | 'exchange')
}
>
<option value="imap">IMAP</option>
<option value="exchange">Exchange</option>
</select>
</div>
{/* Host */}
<div>
<label htmlFor="dkv-host" className={labelCls}>
{t('form.host')} *
</label>
<input
id="dkv-host"
type="text"
required
className={inputCls}
value={form.host}
onChange={(e) => update('host', e.target.value)}
/>
</div>
{/* Port */}
<div>
<label htmlFor="dkv-port" className={labelCls}>
{t('form.port')} *
</label>
<input
id="dkv-port"
type="number"
required
className={inputCls}
value={form.port}
onChange={(e) => update('port', e.target.value)}
/>
</div>
{/* Verschlüsselung */}
<div>
<label htmlFor="dkv-encryption" className={labelCls}>
{t('form.encryption')} *
</label>
<select
id="dkv-encryption"
className={inputCls}
value={form.encryption}
onChange={(e) =>
update('encryption', e.target.value as 'none' | 'starttls' | 'ssl-tls')
}
>
<option value="none">{t('form.encryptionNone')}</option>
<option value="starttls">STARTTLS</option>
<option value="ssl-tls">SSL-TLS</option>
</select>
</div>
{/* Ordner */}
<div>
<label htmlFor="dkv-folder" className={labelCls}>
{t('form.folder')} *
</label>
<input
id="dkv-folder"
type="text"
required
className={inputCls}
value={form.folder}
onChange={(e) => update('folder', e.target.value)}
/>
</div>
{/* Absenderfilter */}
<div>
<label htmlFor="dkv-sender-filter" className={labelCls}>
{t('form.senderFilter')}
</label>
<input
id="dkv-sender-filter"
type="email"
placeholder="rechnung@dkv.com"
className={inputCls}
value={form.senderFilter}
onChange={(e) => update('senderFilter', e.target.value)}
/>
</div>
{/* Abrufintervall */}
<div>
<label htmlFor="dkv-poll-interval" className={labelCls}>
{t('form.pollInterval')} *
</label>
<div className="flex items-center gap-2">
<input
id="dkv-poll-interval"
type="number"
required
min={5}
className="h-9 w-28 rounded border border-border bg-background px-3 text-sm text-foreground"
value={form.pollIntervalMin}
onChange={(e) => update('pollIntervalMin', e.target.value)}
/>
<span className="text-sm text-muted-foreground">
{t('form.pollIntervalSuffix')}
</span>
</div>
</div>
{/* Benutzername */}
<div>
<label htmlFor="dkv-username" className={labelCls}>
{t('form.username')}
</label>
<input
id="dkv-username"
type="text"
className={inputCls}
value={form.username}
onChange={(e) => update('username', e.target.value)}
/>
</div>
{/* Passwort — T-07-12: never pre-filled from server */}
<div>
<label htmlFor="dkv-password" className={labelCls}>
{t('form.password')}
</label>
<div className="relative max-w-md">
<input
id="dkv-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}
placeholder={
/* Hint that a password is stored server-side (without revealing it) */
'••••••••'
}
onChange={(e) => update('password', e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={
showPassword ? t('form.hidePassword') : t('form.showPassword')
}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
</div>
{/* Export-Empfänger */}
<div>
<label htmlFor="dkv-export-recipient" className={labelCls}>
{t('form.exportRecipient')} *
</label>
<input
id="dkv-export-recipient"
type="email"
required
className={inputCls}
value={form.exportRecipient}
onChange={(e) => update('exportRecipient', e.target.value)}
/>
</div>
{/* Fahrzeug-Formatstring */}
<div>
<label htmlFor="dkv-vehicle-format" className={labelCls}>
{t('form.vehicleFormat')} *
</label>
<input
id="dkv-vehicle-format"
type="text"
required
className={inputCls}
value={form.vehicleFormatString}
onChange={(e) => update('vehicleFormatString', e.target.value)}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t('form.vehicleFormatHelp')}
</p>
</div>
{/* Aktiv — toggle switch per UI-SPEC */}
<div className="flex items-center gap-3">
<label
htmlFor="dkv-active"
className="text-sm text-foreground cursor-pointer"
>
{t('form.active')}
</label>
<button
id="dkv-active"
type="button"
role="switch"
aria-checked={form.isActive}
onClick={() => update('isActive', !form.isActive)}
className="relative flex-shrink-0 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
style={{
width: 40,
height: 22,
background: form.isActive ? 'var(--primary)' : 'var(--muted)',
}}
>
<span
className="absolute top-0.5 rounded-full bg-white shadow transition-transform"
style={{
width: 18,
height: 18,
left: 2,
transform: form.isActive ? 'translateX(18px)' : 'translateX(0)',
}}
/>
</button>
</div>
{/* Form actions row per UI-SPEC */}
<div className="flex gap-3 pt-4 border-t border-border mt-6">
<button
type="button"
onClick={handleTestConnection}
disabled={isTesting || isSaving}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{isTesting ? t('form.testTesting') : t('form.testConnection')}
</button>
<button
type="button"
onClick={handleSave}
disabled={isSaving || isTesting}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('form.save')}
</button>
</div>
{/* Inline test feedback */}
{testResult !== null && (
<p
className="text-sm"
style={{
color: testResult.success
? 'oklch(0.40 0.15 148)'
: 'var(--destructive)',
}}
>
{testResult.success
? t('form.testSuccess')
: t('form.testFailed', {
error: testResult.message ?? 'Unbekannter Fehler',
})}
</p>
)}
{/* Inline save feedback */}
{saveSuccess && (
<p
className="text-sm"
style={{ color: 'oklch(0.40 0.15 148)' }}
>
Einstellungen gespeichert.
</p>
)}
{saveError && (
<p className="text-sm text-destructive">{saveError}</p>
)}
</div>
);
}
@@ -0,0 +1,56 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { InboxConfigForm } from './components/InboxConfigForm';
type ActiveTab = 'inbox' | 'vehicles';
/**
* DKV Fleet Module Settings Page (Surface B from 07-UI-SPEC).
* Renders a tab bar (Posteingang / Fahrzeuge) with InboxConfigForm on Tab 1.
* Tab 2 navigates to /modules/dkv-fleet/vehicles (separate route per plan split).
*/
export default function DkvFleetSettingsPage() {
const t = useTranslations('dkvFleet');
const [activeTab, setActiveTab] = useState<ActiveTab>('inbox');
const tabCls = (tab: ActiveTab) =>
`px-4 py-2 text-sm transition-colors ${
activeTab === tab
? 'border-b-2 border-primary text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
}`;
return (
<div className="mx-auto max-w-5xl p-6">
<h1 className="text-2xl font-semibold tracking-tight mb-6">
{t('settingsTitle')}
</h1>
{/* Tab bar per UI-SPEC */}
<div className="border-b border-border mb-6">
<div className="flex">
<button
type="button"
className={tabCls('inbox')}
onClick={() => setActiveTab('inbox')}
>
{t('tabs.inbox')}
</button>
{/* Vehicles tab navigates to its own route */}
<Link
href="/modules/dkv-fleet/vehicles"
className={`px-4 py-2 text-sm transition-colors text-muted-foreground hover:text-foreground`}
>
{t('tabs.vehicles')}
</Link>
</div>
</div>
{/* Tab 1: Posteingang → InboxConfigForm */}
{activeTab === 'inbox' && <InboxConfigForm />}
</div>
);
}