refactor(web): make SmtpSettingsPanel self-contained, eliminating prop drilling

SmtpSettingsPanel now owns its form state, save/test mutations, and feedback state
internally. Props reduced from 17 to 2 (initialSettings + onSettingsSaved callback).
Removes 7 useState declarations, 2 mutation definitions, and 1 handler from the parent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 23:20:36 +02:00
parent bfcadd2c52
commit c9be7c9bbf
2 changed files with 73 additions and 116 deletions
@@ -3,10 +3,7 @@
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared"; import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { import { AiProviderPanel, GenerationSettingsPanel } from "./system-settings/AiSettingsPanels.js";
AiProviderPanel,
GenerationSettingsPanel,
} from "./system-settings/AiSettingsPanels.js";
import { LegacyRuntimeSecretsNotice } from "./system-settings/LegacyRuntimeSecretsNotice.js"; import { LegacyRuntimeSecretsNotice } from "./system-settings/LegacyRuntimeSecretsNotice.js";
import { import {
type ImageProvider, type ImageProvider,
@@ -52,13 +49,6 @@ export function SystemSettingsClient() {
const [imageProvider, setImageProvider] = useState<ImageProvider>("dalle"); const [imageProvider, setImageProvider] = useState<ImageProvider>("dalle");
const [geminiModel, setGeminiModel] = useState(""); const [geminiModel, setGeminiModel] = useState("");
const [imageSaved, setImageSaved] = useState(false); const [imageSaved, setImageSaved] = useState(false);
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState(587);
const [smtpUser, setSmtpUser] = useState("");
const [smtpFrom, setSmtpFrom] = useState("");
const [smtpTls, setSmtpTls] = useState(true);
const [smtpSaved, setSmtpSaved] = useState(false);
const [smtpTestResult, setSmtpTestResult] = useState<SaveResult | null>(null);
const [anonymizationEnabled, setAnonymizationEnabled] = useState(false); const [anonymizationEnabled, setAnonymizationEnabled] = useState(false);
const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de"); const [anonymizationDomain, setAnonymizationDomain] = useState("superhartmut.de");
const [anonymizationSaved, setAnonymizationSaved] = useState(false); const [anonymizationSaved, setAnonymizationSaved] = useState(false);
@@ -96,11 +86,6 @@ export function SystemSettingsClient() {
setDalleEndpoint(settings.azureDalleEndpoint ?? ""); setDalleEndpoint(settings.azureDalleEndpoint ?? "");
setImageProvider((settings.imageProvider ?? "dalle") as ImageProvider); setImageProvider((settings.imageProvider ?? "dalle") as ImageProvider);
setGeminiModel(settings.geminiModel ?? ""); setGeminiModel(settings.geminiModel ?? "");
setSmtpHost(settings.smtpHost ?? "");
setSmtpPort(settings.smtpPort ?? 587);
setSmtpUser(settings.smtpUser ?? "");
setSmtpFrom(settings.smtpFrom ?? "");
setSmtpTls(settings.smtpTls ?? true);
setAnonymizationEnabled(settings.anonymizationEnabled ?? false); setAnonymizationEnabled(settings.anonymizationEnabled ?? false);
setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de"); setAnonymizationDomain(settings.anonymizationDomain ?? "superhartmut.de");
setVacationDefaultDays(settings.vacationDefaultDays ?? 28); setVacationDefaultDays(settings.vacationDefaultDays ?? 28);
@@ -163,21 +148,6 @@ export function SystemSettingsClient() {
onSuccess: (data) => setRecomputeResult(data), onSuccess: (data) => setRecomputeResult(data),
}); });
const saveSmtpMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => {
setSmtpSaved(true);
setSmtpTestResult(null);
setLegacyCleanupResult(null);
invalidateSystemSettings();
setTimeout(() => setSmtpSaved(false), 3000);
},
});
const testSmtpMutation = trpc.settings.testSmtpConnection.useMutation({
onSuccess: (data) => setSmtpTestResult(data),
onError: (error) => setSmtpTestResult({ ok: false, error: error.message }),
});
const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({ const saveAnonymizationMutation = trpc.settings.updateSystemSettings.useMutation({
onSuccess: () => { onSuccess: () => {
setAnonymizationSaved(true); setAnonymizationSaved(true);
@@ -254,16 +224,6 @@ export function SystemSettingsClient() {
}); });
} }
function handleSaveSmtp() {
saveSmtpMutation.mutate({
smtpHost: smtpHost || undefined,
smtpPort,
smtpUser: smtpUser || undefined,
smtpFrom: smtpFrom || undefined,
smtpTls,
});
}
function handleSaveVacation() { function handleSaveVacation() {
saveVacationMutation.mutate({ vacationDefaultDays }); saveVacationMutation.mutate({ vacationDefaultDays });
} }
@@ -292,8 +252,8 @@ export function SystemSettingsClient() {
function handleClearLegacyRuntimeSecrets() { function handleClearLegacyRuntimeSecrets() {
if ( if (
typeof window !== "undefined" typeof window !== "undefined" &&
&& !window.confirm( !window.confirm(
"Clear all legacy runtime secrets from database storage? Environment-based deployment secrets must already be configured.", "Clear all legacy runtime secrets from database storage? Environment-based deployment secrets must already be configured.",
) )
) { ) {
@@ -423,25 +383,7 @@ export function SystemSettingsClient() {
onTestGemini={() => testGeminiMutation.mutate()} onTestGemini={() => testGeminiMutation.mutate()}
/> />
<SmtpSettingsPanel <SmtpSettingsPanel initialSettings={settings} onSettingsSaved={invalidateSystemSettings} />
smtpHost={smtpHost}
smtpPort={smtpPort}
smtpUser={smtpUser}
smtpFrom={smtpFrom}
smtpTls={smtpTls}
smtpSaved={smtpSaved}
smtpTestResult={smtpTestResult}
smtpSecret={settings.runtimeSecrets.smtpPassword}
isSaving={saveSmtpMutation.isPending}
isTesting={testSmtpMutation.isPending}
onSmtpHostChange={setSmtpHost}
onSmtpPortChange={setSmtpPort}
onSmtpUserChange={setSmtpUser}
onSmtpFromChange={setSmtpFrom}
onSmtpTlsChange={setSmtpTls}
onSave={handleSaveSmtp}
onTest={() => testSmtpMutation.mutate()}
/>
<VacationSettingsPanel <VacationSettingsPanel
vacationDefaultDays={vacationDefaultDays} vacationDefaultDays={vacationDefaultDays}
@@ -1,3 +1,5 @@
import { useState, useEffect } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { import {
CHECKBOX_ROW_CLASS, CHECKBOX_ROW_CLASS,
@@ -12,44 +14,58 @@ import {
} from "./shared.js"; } from "./shared.js";
type SmtpSettingsPanelProps = { type SmtpSettingsPanelProps = {
smtpHost: string; initialSettings: {
smtpPort: number; smtpHost: string | null;
smtpUser: string; smtpPort: number | null;
smtpFrom: string; smtpUser: string | null;
smtpTls: boolean; smtpFrom: string | null;
smtpSaved: boolean; smtpTls: boolean | null;
smtpTestResult: SaveResult | null; runtimeSecrets: { smtpPassword: RuntimeSecrets["smtpPassword"] };
smtpSecret: RuntimeSecrets["smtpPassword"]; };
isSaving: boolean; onSettingsSaved: () => void;
isTesting: boolean;
onSmtpHostChange: (value: string) => void;
onSmtpPortChange: (value: number) => void;
onSmtpUserChange: (value: string) => void;
onSmtpFromChange: (value: string) => void;
onSmtpTlsChange: (value: boolean) => void;
onSave: () => void;
onTest: () => void;
}; };
export function SmtpSettingsPanel({ export function SmtpSettingsPanel({ initialSettings, onSettingsSaved }: SmtpSettingsPanelProps) {
smtpHost, const [smtpHost, setSmtpHost] = useState("");
smtpPort, const [smtpPort, setSmtpPort] = useState(587);
smtpUser, const [smtpUser, setSmtpUser] = useState("");
smtpFrom, const [smtpFrom, setSmtpFrom] = useState("");
smtpTls, const [smtpTls, setSmtpTls] = useState(true);
smtpSaved, const [saved, setSaved] = useState(false);
smtpTestResult, const [testResult, setTestResult] = useState<SaveResult | null>(null);
smtpSecret,
isSaving, useEffect(() => {
isTesting, setSmtpHost(initialSettings.smtpHost ?? "");
onSmtpHostChange, setSmtpPort(initialSettings.smtpPort ?? 587);
onSmtpPortChange, setSmtpUser(initialSettings.smtpUser ?? "");
onSmtpUserChange, setSmtpFrom(initialSettings.smtpFrom ?? "");
onSmtpFromChange, setSmtpTls(initialSettings.smtpTls ?? true);
onSmtpTlsChange, }, [initialSettings]);
onSave,
onTest, const saveMutation = trpc.settings.updateSystemSettings.useMutation({
}: SmtpSettingsPanelProps) { onSuccess: () => {
setSaved(true);
setTestResult(null);
onSettingsSaved();
setTimeout(() => setSaved(false), 3000);
},
});
const testMutation = trpc.settings.testSmtpConnection.useMutation({
onSuccess: (data) => setTestResult(data),
onError: (error) => setTestResult({ ok: false, error: error.message }),
});
function handleSave() {
saveMutation.mutate({
smtpHost: smtpHost || undefined,
smtpPort,
smtpUser: smtpUser || undefined,
smtpFrom: smtpFrom || undefined,
smtpTls,
});
}
return ( return (
<div className={PANEL_CLASS}> <div className={PANEL_CLASS}>
<div> <div>
@@ -74,7 +90,7 @@ export function SmtpSettingsPanel({
type="text" type="text"
className={INPUT_CLASS} className={INPUT_CLASS}
value={smtpHost} value={smtpHost}
onChange={(event) => onSmtpHostChange(event.target.value)} onChange={(event) => setSmtpHost(event.target.value)}
placeholder="smtp.example.com" placeholder="smtp.example.com"
/> />
</div> </div>
@@ -89,7 +105,7 @@ export function SmtpSettingsPanel({
type="number" type="number"
className={INPUT_CLASS} className={INPUT_CLASS}
value={smtpPort} value={smtpPort}
onChange={(event) => onSmtpPortChange(parseInt(event.target.value, 10))} onChange={(event) => setSmtpPort(parseInt(event.target.value, 10))}
min={1} min={1}
max={65535} max={65535}
/> />
@@ -97,15 +113,14 @@ export function SmtpSettingsPanel({
<div> <div>
<label className={LABEL_CLASS}> <label className={LABEL_CLASS}>
<span className="flex items-center"> <span className="flex items-center">
SMTP Username{" "} SMTP Username <InfoTooltip content="Authentication username for the SMTP server." />
<InfoTooltip content="Authentication username for the SMTP server." />
</span> </span>
</label> </label>
<input <input
type="text" type="text"
className={INPUT_CLASS} className={INPUT_CLASS}
value={smtpUser} value={smtpUser}
onChange={(event) => onSmtpUserChange(event.target.value)} onChange={(event) => setSmtpUser(event.target.value)}
placeholder="user@example.com" placeholder="user@example.com"
autoComplete="off" autoComplete="off"
/> />
@@ -121,7 +136,7 @@ export function SmtpSettingsPanel({
type="email" type="email"
className={INPUT_CLASS} className={INPUT_CLASS}
value={smtpFrom} value={smtpFrom}
onChange={(event) => onSmtpFromChange(event.target.value)} onChange={(event) => setSmtpFrom(event.target.value)}
placeholder="noreply@capakraken.app" placeholder="noreply@capakraken.app"
/> />
</div> </div>
@@ -130,7 +145,7 @@ export function SmtpSettingsPanel({
type="checkbox" type="checkbox"
id="smtpTls" id="smtpTls"
checked={smtpTls} checked={smtpTls}
onChange={(event) => onSmtpTlsChange(event.target.checked)} onChange={(event) => setSmtpTls(event.target.checked)}
className="rounded border-gray-300 text-brand-600" className="rounded border-gray-300 text-brand-600"
/> />
<label <label
@@ -145,39 +160,39 @@ export function SmtpSettingsPanel({
<RuntimeSecretCard <RuntimeSecretCard
title="SMTP Password" title="SMTP Password"
description="SMTP credentials are provisioned outside the application and injected at runtime." description="SMTP credentials are provisioned outside the application and injected at runtime."
secret={smtpSecret} secret={initialSettings.runtimeSecrets.smtpPassword}
optionalNote="Provision SMTP_PASSWORD in the deployment target used by the API service." optionalNote="Provision SMTP_PASSWORD in the deployment target used by the API service."
/> />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
type="button" type="button"
onClick={onSave} onClick={handleSave}
disabled={isSaving} disabled={saveMutation.isPending}
className={PRIMARY_BUTTON_CLASS} className={PRIMARY_BUTTON_CLASS}
> >
{isSaving ? "Saving" : "Save SMTP Settings"} {saveMutation.isPending ? "Saving\u2026" : "Save SMTP Settings"}
</button> </button>
<button <button
type="button" type="button"
onClick={onTest} onClick={() => testMutation.mutate()}
disabled={isTesting} disabled={testMutation.isPending}
className={SECONDARY_BUTTON_CLASS} className={SECONDARY_BUTTON_CLASS}
> >
{isTesting ? "Testing" : "Test Connection"} {testMutation.isPending ? "Testing\u2026" : "Test Connection"}
</button> </button>
{smtpSaved ? ( {saved ? (
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span> <span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
) : null} ) : null}
{smtpTestResult ? ( {testResult ? (
<span <span
className={`text-sm font-medium ${ className={`text-sm font-medium ${
smtpTestResult.ok testResult.ok
? "text-green-600 dark:text-green-400" ? "text-green-600 dark:text-green-400"
: "text-red-500 dark:text-red-400" : "text-red-500 dark:text-red-400"
}`} }`}
> >
{smtpTestResult.ok ? " Connection successful" : ` ${smtpTestResult.error}`} {testResult.ok ? "\u2713 Connection successful" : `\u2717 ${testResult.error}`}
</span> </span>
) : null} ) : null}
</div> </div>