feat: verschlüsselte Integrationseinstellungen, Haushaltsmitglieder mit Ersteller, Backup-Download

This commit is contained in:
opencode committed 2026-10-18 14:19:03 +02:00
1 parent a1195223e0
commit 267f4fcf70
21 files changed
+724 -30

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
"use client";
import { useEffect, useState } from "react";
type Status = {
smtp: { host: string; port: number; user: string; from: string; passGesetzt: boolean };
telegram: { botTokenGesetzt: boolean };
logo: { secretGesetzt: boolean; publishableKey: string };
verschluesselungBereit: boolean;
};
const inputClass =
"mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60";
async function saveSection(section: string, values: Record<string, string>) {
const res = await fetch("/api/settings/integrations", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ section, values }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message ?? `Fehler: ${(data.errors ?? []).join(", ")}`);
return data.status as Status;
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">{title}</h2>
<div className="mt-3">{children}</div>
</section>
);
}
function SecretHint({ gesetzt }: { gesetzt: boolean }) {
return (
<p className="mt-1 text-[13px] text-muted">
{gesetzt ? "Gespeichert. Leer lassen, um den Wert zu behalten." : "Noch nicht gespeichert."}
</p>
);
}
export default function IntegrationsForm() {
const [status, setStatus] = useState<Status | null>(null);
const [loadError, setLoadError] = useState("");
const [notice, setNotice] = useState("");
const [smtp, setSmtp] = useState({ host: "", port: "587", user: "", pass: "", from: "" });
const [telegramToken, setTelegramToken] = useState("");
const [logoSecret, setLogoSecret] = useState("");
const [logoPublic, setLogoPublic] = useState("");
useEffect(() => {
fetch("/api/settings/integrations")
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("laden fehlgeschlagen"))))
.then((s: Status) => {
setStatus(s);
setSmtp({ host: s.smtp.host, port: String(s.smtp.port), user: s.smtp.user, pass: "", from: s.smtp.from });
setLogoPublic(s.logo.publishableKey);
})
.catch(() => setLoadError("Einstellungen konnten nicht geladen werden."));
}, []);
async function onSave(section: "smtp" | "telegram" | "logo", values: Record<string, string>, okText: string) {
setNotice("");
try {
const s = await saveSection(section, values);
setStatus(s);
if (section === "smtp") setSmtp((v) => ({ ...v, pass: "" }));
if (section === "telegram") setTelegramToken("");
if (section === "logo") {
setLogoSecret("");
setLogoPublic(s.logo.publishableKey);
}
setNotice(okText);
} catch (e) {
setNotice(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
}
}
if (loadError) return <p role="alert" className="text-sm font-medium text-danger">{loadError}</p>;
if (!status) return <p className="text-sm text-muted">Wird geladen …</p>;
return (
<div className="grid gap-4">
<Section title="E-Mail (SMTP)">
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="smtp-host" className="block text-[13px] font-semibold">Server</label>
<input id="smtp-host" value={smtp.host} onChange={(e) => setSmtp({ ...smtp, host: e.target.value })} placeholder="mail.beispiel.de" autoComplete="off" className={inputClass} />
</div>
<div>
<label htmlFor="smtp-port" className="block text-[13px] font-semibold">Port</label>
<input id="smtp-port" inputMode="numeric" value={smtp.port} onChange={(e) => setSmtp({ ...smtp, port: e.target.value })} placeholder="587" autoComplete="off" className={inputClass} />
</div>
<div>
<label htmlFor="smtp-user" className="block text-[13px] font-semibold">Benutzername</label>
<input id="smtp-user" value={smtp.user} onChange={(e) => setSmtp({ ...smtp, user: e.target.value })} autoComplete="username" className={inputClass} />
</div>
<div>
<label htmlFor="smtp-pass" className="block text-[13px] font-semibold">Passwort</label>
<input id="smtp-pass" type="password" value={smtp.pass} onChange={(e) => setSmtp({ ...smtp, pass: e.target.value })} autoComplete="new-password" placeholder={status.smtp.passGesetzt ? "••••••••" : ""} className={inputClass} />
<SecretHint gesetzt={status.smtp.passGesetzt} />
</div>
<div>
<label htmlFor="smtp-from" className="block text-[13px] font-semibold">Absender</label>
<input id="smtp-from" type="email" value={smtp.from} onChange={(e) => setSmtp({ ...smtp, from: e.target.value })} placeholder="gutscheine@beispiel.de" autoComplete="email" className={inputClass} />
</div>
</div>
<button
type="button"
onClick={() => onSave("smtp", { smtp_host: smtp.host, smtp_port: smtp.port, smtp_user: smtp.user, smtp_pass: smtp.pass, mail_from: smtp.from }, "E-Mail-Einstellungen gespeichert.")}
className="mt-4 h-11 rounded-field bg-brand-600 px-6 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99]"
>
Speichern
</button>
</Section>
<Section title="Telegram">
<label htmlFor="tg-token" className="block text-[13px] font-semibold">Bot-Token</label>
<input
id="tg-token"
type="password"
value={telegramToken}
onChange={(e) => setTelegramToken(e.target.value)}
autoComplete="off"
placeholder={status.telegram.botTokenGesetzt ? "••••••••" : "123456:ABC-DEF…"}
className={inputClass}
/>
<SecretHint gesetzt={status.telegram.botTokenGesetzt} />
<button
type="button"
onClick={() => onSave("telegram", { telegram_bot_token: telegramToken }, "Telegram-Token gespeichert.")}
className="mt-4 h-11 rounded-field bg-brand-600 px-6 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99]"
>
Speichern
</button>
</Section>
<Section title="Anbieterlogos (Logo.dev)">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="logo-secret" className="block text-[13px] font-semibold">Secret-Key (Suche)</label>
<input
id="logo-secret"
type="password"
value={logoSecret}
onChange={(e) => setLogoSecret(e.target.value)}
autoComplete="off"
placeholder={status.logo.secretGesetzt ? "••••••••" : "sk_…"}
className={inputClass}
/>
<SecretHint gesetzt={status.logo.secretGesetzt} />
</div>
<div>
<label htmlFor="logo-public" className="block text-[13px] font-semibold">Public-Key (Anzeige)</label>
<input
id="logo-public"
value={logoPublic}
onChange={(e) => setLogoPublic(e.target.value)}
autoComplete="off"
placeholder="pk_…"
className={inputClass}
/>
</div>
</div>
<button
type="button"
onClick={() => onSave("logo", { logo_dev_secret_key: logoSecret, logo_dev_publishable_key: logoPublic }, "Logo.dev-Schlüssel gespeichert.")}
className="mt-4 h-11 rounded-field bg-brand-600 px-6 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99]"
>
Speichern
</button>
</Section>
{notice && <p role="status" className="text-sm font-medium text-muted">{notice}</p>}
</div>
);
}