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

+1
View File
@@ -2,6 +2,7 @@ DATABASE_URL=file:/data/gutscheine.db
APP_USER=haushalt APP_USER=haushalt
APP_PASSWORD_HASH=$2b$10$REPLACE_ME APP_PASSWORD_HASH=$2b$10$REPLACE_ME
SESSION_SECRET=REPLACE_WITH_32_CHARS_MIN SESSION_SECRET=REPLACE_WITH_32_CHARS_MIN
SETTINGS_ENCRYPTION_KEY=REPLACE_WITH_OWN_32_CHARS_MIN
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER= SMTP_USER=
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/lib/db";
export async function GET() {
const db = getDb();
const members = await db.householdMember.findMany({ orderBy: { name: "asc" } });
return NextResponse.json(members);
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const name = String(body.name ?? "").trim();
if (!name) return NextResponse.json({ errors: ["name"] }, { status: 400 });
if (name.length > 100) return NextResponse.json({ errors: ["name"] }, { status: 400 });
const db = getDb();
const existing = await db.householdMember.findUnique({ where: { name } });
if (existing) return NextResponse.json({ errors: ["name"] }, { status: 409 });
const created = await db.householdMember.create({ data: { name } });
return NextResponse.json(created, { status: 201 });
}
export async function DELETE(req: NextRequest) {
const id = Number(req.nextUrl.searchParams.get("id"));
if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 });
const db = getDb();
const existing = await db.householdMember.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 });
await db.householdMember.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { randomBytes } from "crypto";
import { unlink } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { getDb } from "@/lib/db";
/**
* Lädt einen konsistenten SQLite-Snapshot herunter (VACUUM INTO läuft
* transaktional, auch bei parallelen Schreibzugriffen). Enthält Gutscheine,
* Kontakte, Mitglieder, Regeln, Logs und verschlüsselte Einstellungen.
* Wiederherstellen: Datei als /data/gutscheine.db ablegen (gleicher
* SETTINGS_ENCRYPTION_KEY erforderlich).
*/
export async function GET() {
const db = getDb();
const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "");
const tmpPath = join(tmpdir(), `gutscheintracker-backup-${stamp}-${randomBytes(4).toString("hex")}.db`);
try {
await db.$executeRawUnsafe(`VACUUM INTO '${tmpPath.replace(/'/g, "''")}'`);
const { readFile } = await import("fs/promises");
const data = await readFile(tmpPath);
await unlink(tmpPath).catch(() => {});
return new NextResponse(new Uint8Array(data), {
headers: {
"content-type": "application/x-sqlite3",
"content-disposition": `attachment; filename="gutscheintracker-backup-${stamp}.db"`,
},
});
} catch (e) {
await unlink(tmpPath).catch(() => {});
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Backup fehlgeschlagen" },
{ status: 500 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import {
getIntegrationStatus,
saveIntegrationSection,
validateIntegrationSection,
type IntegrationSection,
} from "@/lib/integration-settings";
import { canEncrypt } from "@/lib/settings-crypto";
export async function GET() {
return NextResponse.json(await getIntegrationStatus());
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const section = String(body.section ?? "");
const values = (body.values ?? {}) as Record<string, unknown>;
const errs = validateIntegrationSection(section, values);
if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 });
const stringValues: Record<string, string> = {};
for (const [k, v] of Object.entries(values)) {
if (typeof v === "string") stringValues[k] = v;
}
const needsEncryption = Object.entries(stringValues).some(
([k, v]) => v !== "" && (k === "smtp_pass" || k === "telegram_bot_token" || k === "logo_dev_secret_key")
);
if (needsEncryption && !canEncrypt()) {
return NextResponse.json(
{ errors: ["verschluesselung"], message: "SETTINGS_ENCRYPTION_KEY fehlt (min. 32 Zeichen in .env)" },
{ status: 503 }
);
}
const saved = await saveIntegrationSection(section as IntegrationSection, stringValues);
return NextResponse.json({ ok: true, saved, status: await getIntegrationStatus() });
}
+1 -1
View File
@@ -9,7 +9,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id:
const db = getDb(); const db = getDb();
const v = await db.voucher.findUnique({ where: { id: numId }, include: { contacts: { include: { contact: true } } } }); const v = await db.voucher.findUnique({ where: { id: numId }, include: { contacts: { include: { contact: true } } } });
if (!v) return NextResponse.json({ error: "not found" }, { status: 404 }); if (!v) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(withLogoUrl(v)); return NextResponse.json(await withLogoUrl(v));
} }
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+10 -1
View File
@@ -17,7 +17,7 @@ export async function GET(req: NextRequest) {
if (filter === "aktiv") return s === "aktiv"; if (filter === "aktiv") return s === "aktiv";
return true; return true;
}); });
return NextResponse.json(filtered.map(withLogoUrl)); return NextResponse.json(await Promise.all(filtered.map(withLogoUrl)));
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -44,6 +44,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ errors: ["contactIds"] }, { status: 400 }); return NextResponse.json({ errors: ["contactIds"] }, { status: 400 });
} }
} }
let createdBy: string | null = null;
if (body.memberId !== undefined && body.memberId !== null && String(body.memberId).trim() !== "") {
const memberId = Number(body.memberId);
if (!Number.isInteger(memberId)) return NextResponse.json({ errors: ["memberId"] }, { status: 400 });
const member = await db.householdMember.findUnique({ where: { id: memberId } });
if (!member) return NextResponse.json({ errors: ["memberId"] }, { status: 400 });
createdBy = member.name;
}
const providerDomain = normalizeDomain( const providerDomain = normalizeDomain(
body.providerDomain === undefined || body.providerDomain === null ? null : String(body.providerDomain) body.providerDomain === undefined || body.providerDomain === null ? null : String(body.providerDomain)
); );
@@ -72,6 +80,7 @@ export async function POST(req: NextRequest) {
kategorie: input.kategorie, kategorie: input.kategorie,
notiz: input.notiz, notiz: input.notiz,
providerDomain, providerDomain,
createdBy,
contacts: contactIds.length ? { create: contactIds.map((contactId) => ({ contactId })) } : undefined, contacts: contactIds.length ? { create: contactIds.map((contactId) => ({ contactId })) } : undefined,
rules: { rules: {
create: reminderDays.map((tageVorher) => ({ tageVorher })), create: reminderDays.map((tageVorher) => ({ tageVorher })),
+5 -1
View File
@@ -51,6 +51,7 @@ async function VoucherList({ filter }: { filter: string }) {
if (filter === "aktiv") return s === "aktiv"; if (filter === "aktiv") return s === "aktiv";
return true; return true;
}); });
const withLogos = await Promise.all(filtered.map(withLogoUrl));
if (filtered.length === 0) { if (filtered.length === 0) {
return ( return (
<div className="rounded-card border border-line bg-white p-10 text-center"> <div className="rounded-card border border-line bg-white p-10 text-center">
@@ -66,7 +67,7 @@ async function VoucherList({ filter }: { filter: string }) {
} }
return ( return (
<ul className="grid gap-3"> <ul className="grid gap-3">
{filtered.map(withLogoUrl).map((v) => { {withLogos.map((v) => {
const s = getVoucherStatus(v); const s = getVoucherStatus(v);
return ( return (
<li key={v.id} className="rounded-card border border-line bg-white p-5 transition-shadow hover:shadow-[0_8px_24px_rgba(32,55,39,0.08)]"> <li key={v.id} className="rounded-card border border-line bg-white p-5 transition-shadow hover:shadow-[0_8px_24px_rgba(32,55,39,0.08)]">
@@ -81,6 +82,9 @@ async function VoucherList({ filter }: { filter: string }) {
{v.anbieter} · {formatEuro(v.restCents)} von {formatEuro(v.wertCents)} {v.anbieter} · {formatEuro(v.restCents)} von {formatEuro(v.wertCents)}
{v.ablaufdatum ? ` · bis ${v.ablaufdatum.toLocaleDateString("de-DE")}` : ""} {v.ablaufdatum ? ` · bis ${v.ablaufdatum.toLocaleDateString("de-DE")}` : ""}
</p> </p>
<p className="mt-0.5 text-[13px] text-muted">
{v.createdBy ? `Angelegt von ${v.createdBy}` : "Ersteller nicht erfasst"}
</p>
</div> </div>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${STATUS_STYLE[s]}`}> <span className={`shrink-0 rounded-full px-2.5 py-1 text-xs font-semibold ${STATUS_STYLE[s]}`}>
{statusLabel(s)} {statusLabel(s)}
+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>
);
}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useState } from "react";
type Member = { id: number; name: string };
export default function MembersSection() {
const [members, setMembers] = useState<Member[]>([]);
const [name, setName] = useState("");
const [error, setError] = useState("");
async function load() {
const res = await fetch("/api/members");
if (res.ok) setMembers(await res.json());
}
useEffect(() => {
load();
}, []);
async function add() {
setError("");
const value = name.trim();
if (!value) {
setError("Bitte einen Namen eingeben.");
return;
}
const res = await fetch("/api/members", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: value }),
});
if (!res.ok) {
setError(res.status === 409 ? "Dieser Name existiert bereits." : "Speichern fehlgeschlagen.");
return;
}
setName("");
load();
}
async function remove(id: number, memberName: string) {
if (!window.confirm(`„${memberName}“ aus der Haushaltsliste entfernen? Bereits erfasste Gutscheine behalten den Namen.`)) return;
const res = await fetch(`/api/members?id=${id}`, { method: "DELETE" });
if (res.ok) load();
}
return (
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Haushaltsmitglieder</h2>
<p className="mt-1 text-sm text-muted">Wer einen Gutschein anlegt, wählt hier seinen Namen aus.</p>
<div className="mt-3 flex gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
placeholder="Name hinzufügen"
aria-label="Name hinzufügen"
maxLength={100}
className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink"
/>
<button
type="button"
onClick={add}
className="h-11 shrink-0 rounded-field bg-brand-600 px-5 text-sm font-semibold text-white transition-colors hover:bg-brand-700 active:scale-[0.99]"
>
Hinzufügen
</button>
</div>
{error && (
<p role="alert" className="mt-2 text-[13px] font-medium text-danger">
{error}
</p>
)}
{members.length > 0 && (
<ul className="mt-3 divide-y divide-line">
{members.map((m) => (
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
<span className="font-medium">{m.name}</span>
<button
type="button"
onClick={() => remove(m.id, m.name)}
className="rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Entfernen
</button>
</li>
))}
</ul>
)}
</section>
);
}
+18 -3
View File
@@ -3,6 +3,8 @@ import Shell from "../components/shell";
import { getDb } from "@/lib/db"; import { getDb } from "@/lib/db";
import { DEFAULT_TAGE_VORHER } from "@/lib/settings"; import { DEFAULT_TAGE_VORHER } from "@/lib/settings";
import { TestButtons } from "./test-buttons"; import { TestButtons } from "./test-buttons";
import IntegrationsForm from "./integrations-form";
import MembersSection from "./members";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -36,9 +38,11 @@ export default async function SettingsPage() {
Kontakte verwalten Kontakte verwalten
</Link> </Link>
</section> </section>
<MembersSection />
<Card title="Standard-Erinnerungstage"> <Card title="Standard-Erinnerungstage">
<p>Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p> <p>Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p>
</Card> </Card>
<IntegrationsForm />
<TestButtons /> <TestButtons />
<Card title="Letzter Cron-Lauf"> <Card title="Letzter Cron-Lauf">
{last ? ( {last ? (
@@ -50,9 +54,20 @@ export default async function SettingsPage() {
<p>Noch keine Benachrichtigung versendet.</p> <p>Noch keine Benachrichtigung versendet.</p>
)} )}
</Card> </Card>
<Card title="Konfiguration und Backup"> <Card title="Backup">
<p>SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM, TELEGRAM_BOT_TOKEN, LOGO_DEV_SECRET_KEY, LOGO_DEV_PUBLISHABLE_KEY, CRON_SECRET, APP_URL.</p> <p>Gesamte Datenbank als Datei sichern, inklusive Gutscheine, Kontakte, Mitglieder und verschlüsselte Einstellungen.</p>
<p className="mt-2">SQLite-Datei sichern: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p> <a
href="/api/settings/backup"
download
className="mt-3 inline-block rounded-field bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Datenbank-Backup herunterladen
</a>
<p className="mt-2">Wiederherstellen am Server: Datei als <code>/data/gutscheine.db</code> ablegen. Derselbe <code>SETTINGS_ENCRYPTION_KEY</code> muss erhalten bleiben.</p>
</Card>
<Card title="Konfiguration (.env)">
<p>APP_USER, APP_PASSWORD_HASH, SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, DATABASE_URL, CRON_SECRET, APP_URL. SMTP, Telegram und Logo.dev lassen sich oben in der App verwalten; <code>.env</code> dient als Rückfall.</p>
<p className="mt-2">Zusätzlich manuell: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p>
</Card> </Card>
</div> </div>
</Shell> </Shell>
+2
View File
@@ -17,6 +17,7 @@ type Voucher = {
kategorie: string | null; kategorie: string | null;
notiz: string | null; notiz: string | null;
status: string; status: string;
createdBy?: string | null;
providerDomain?: string | null; providerDomain?: string | null;
logoUrl?: string | null; logoUrl?: string | null;
contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: string | null } }[]; contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: string | null } }[];
@@ -94,6 +95,7 @@ export default function VoucherDetailPage({ params }: { params: Promise<{ id: st
<Row label="Wert">{formatEuro(v.wertCents)}</Row> <Row label="Wert">{formatEuro(v.wertCents)}</Row>
<Row label="Restguthaben">{formatEuro(v.restCents)}</Row> <Row label="Restguthaben">{formatEuro(v.restCents)}</Row>
<Row label="Status">{status === "aktiv" ? "Aktiv" : status === "abgelaufen" ? "Abgelaufen" : "Eingelöst"}</Row> <Row label="Status">{status === "aktiv" ? "Aktiv" : status === "abgelaufen" ? "Abgelaufen" : "Eingelöst"}</Row>
<Row label="Angelegt von">{v.createdBy ? v.createdBy : "Nicht erfasst"}</Row>
{v.ablaufdatum && ( {v.ablaufdatum && (
<Row label="Ablaufdatum">{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</Row> <Row label="Ablaufdatum">{new Date(v.ablaufdatum).toLocaleDateString("de-DE")}</Row>
)} )}
+31 -1
View File
@@ -31,6 +31,8 @@ export default function NewVoucherPage() {
const [serverError, setServerError] = useState(""); const [serverError, setServerError] = useState("");
const [contacts, setContacts] = useState<{ id: number; name: string }[]>([]); const [contacts, setContacts] = useState<{ id: number; name: string }[]>([]);
const [contactIds, setContactIds] = useState<number[]>([]); const [contactIds, setContactIds] = useState<number[]>([]);
const [members, setMembers] = useState<{ id: number; name: string }[]>([]);
const [memberId, setMemberId] = useState("");
const [reminderDays, setReminderDays] = useState<number[]>([...DEFAULT_REMINDER_DAYS]); const [reminderDays, setReminderDays] = useState<number[]>([...DEFAULT_REMINDER_DAYS]);
const [candidates, setCandidates] = useState<LogoCandidate[]>([]); const [candidates, setCandidates] = useState<LogoCandidate[]>([]);
const [logoSearching, setLogoSearching] = useState(false); const [logoSearching, setLogoSearching] = useState(false);
@@ -43,6 +45,10 @@ export default function NewVoucherPage() {
.then((r) => (r.ok ? r.json() : [])) .then((r) => (r.ok ? r.json() : []))
.then(setContacts) .then(setContacts)
.catch(() => {}); .catch(() => {});
fetch("/api/members")
.then((r) => (r.ok ? r.json() : []))
.then(setMembers)
.catch(() => {});
}, []); }, []);
function set(name: string, value: string) { function set(name: string, value: string) {
@@ -101,7 +107,7 @@ export default function NewVoucherPage() {
const res = await fetch("/api/vouchers", { const res = await fetch("/api/vouchers", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ ...input, contactIds, reminderDays, providerDomain: selectedLogo?.domain ?? null }), body: JSON.stringify({ ...input, contactIds, reminderDays, providerDomain: selectedLogo?.domain ?? null, memberId: memberId === "" ? null : Number(memberId) }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -121,6 +127,30 @@ export default function NewVoucherPage() {
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Gutschein anlegen</h1> <h1 className="mt-2 text-2xl font-semibold tracking-tight">Gutschein anlegen</h1>
<p className="mt-1 text-sm text-muted">Alle Angaben lassen sich später noch ändern.</p> <p className="mt-1 text-sm text-muted">Alle Angaben lassen sich später noch ändern.</p>
<form onSubmit={onSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate> <form onSubmit={onSubmit} className="mt-5 rounded-card border border-line bg-white p-5 sm:p-6" noValidate>
<div className="mb-4">
<label htmlFor="memberId" className="block text-[13px] font-semibold">
Angelegt von
</label>
{members.length === 0 ? (
<p className="mt-1.5 text-[13px] text-muted">
Noch keine Haushaltsmitglieder. Lege sie unter <Link href="/settings" className="font-semibold text-brand-700 underline">Einstellungen</Link> an.
</p>
) : (
<select
id="memberId"
value={memberId}
onChange={(e) => setMemberId(e.target.value)}
className={`${inputClass} appearance-none`}
>
<option value="">Nicht erfasst</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{FIELDS.map((f) => ( {FIELDS.map((f) => (
<div key={f.name} className={f.name === "notiz" ? "sm:col-span-2" : undefined}> <div key={f.name} className={f.name === "notiz" ? "sm:col-span-2" : undefined}>
+15 -2
View File
@@ -2,19 +2,32 @@ import { getDb } from "./db";
/** /**
* Stellt additive Schema-Änderungen sicher, ohne dass auf dem Server ein * Stellt additive Schema-Änderungen sicher, ohne dass auf dem Server ein
* separater `prisma migrate`-Lauf nötig ist. Idempotent: fehlende Spalten * separater `prisma migrate`-Lauf nötig ist. Idempotent: fehlende Tabellen
* werden genau einmal ergänzt, alles andere bleibt unangetastet. * und Spalten werden genau einmal ergänzt, alles andere bleibt unangetastet.
* Best-effort: Fehler werden geloggt, der Start läuft trotzdem weiter. * Best-effort: Fehler werden geloggt, der Start läuft trotzdem weiter.
*/ */
export async function ensureSchema(): Promise<void> { export async function ensureSchema(): Promise<void> {
try { try {
const db = getDb(); const db = getDb();
const tables = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM sqlite_master WHERE type='table'`).map(
(t) => t.name
);
if (!tables.includes("AppSetting")) {
await db.$executeRaw`CREATE TABLE "AppSetting" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT NOT NULL, "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
}
if (!tables.includes("HouseholdMember")) {
await db.$executeRaw`CREATE TABLE "HouseholdMember" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`;
await db.$executeRaw`CREATE UNIQUE INDEX "HouseholdMember_name_key" ON "HouseholdMember"("name")`;
}
const cols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('Voucher')`).map( const cols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('Voucher')`).map(
(c) => c.name (c) => c.name
); );
if (!cols.includes("providerDomain")) { if (!cols.includes("providerDomain")) {
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "providerDomain" TEXT`; await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "providerDomain" TEXT`;
} }
if (!cols.includes("createdBy")) {
await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "createdBy" TEXT`;
}
} catch (err) { } catch (err) {
console.error("ensureSchema fehlgeschlagen (Start läuft weiter):", err); console.error("ensureSchema fehlgeschlagen (Start läuft weiter):", err);
} }
+124
View File
@@ -0,0 +1,124 @@
import { getDb } from "./db";
import { canEncrypt, decryptSecret, encryptSecret } from "./settings-crypto";
export const INTEGRATION_SECTIONS = ["smtp", "telegram", "logo"] as const;
export type IntegrationSection = (typeof INTEGRATION_SECTIONS)[number];
const SECRET_KEYS = new Set(["smtp_pass", "telegram_bot_token", "logo_dev_secret_key"]);
const ENV_FALLBACK: Record<string, string> = {
smtp_host: "SMTP_HOST",
smtp_port: "SMTP_PORT",
smtp_user: "SMTP_USER",
smtp_pass: "SMTP_PASS",
mail_from: "MAIL_FROM",
telegram_bot_token: "TELEGRAM_BOT_TOKEN",
logo_dev_secret_key: "LOGO_DEV_SECRET_KEY",
logo_dev_publishable_key: "LOGO_DEV_PUBLISHABLE_KEY",
};
export type SmtpSettings = { host: string; port: number; user: string; pass: string; from: string };
export type TelegramSettings = { botToken: string };
export type LogoSettings = { secretKey: string; publishableKey: string };
async function readStored(key: string): Promise<string | null> {
const db = getDb();
const row = await db.appSetting.findUnique({ where: { key } });
if (!row) return null;
if (SECRET_KEYS.has(key)) {
try {
return decryptSecret(row.value);
} catch {
return null;
}
}
return row.value;
}
async function resolveValue(key: string): Promise<string> {
const stored = await readStored(key);
if (stored !== null && stored !== "") return stored;
return process.env[ENV_FALLBACK[key] ?? ""] ?? "";
}
/** Volle SMTP-Konfiguration: UI-Werte zuerst, sonst .env-Rückfall. */
export async function getSmtpSettings(): Promise<SmtpSettings> {
const [host, port, user, pass, from] = await Promise.all([
resolveValue("smtp_host"),
resolveValue("smtp_port"),
resolveValue("smtp_user"),
resolveValue("smtp_pass"),
resolveValue("mail_from"),
]);
return { host, port: Number(port) || 587, user, pass, from };
}
export async function getTelegramSettings(): Promise<TelegramSettings> {
return { botToken: await resolveValue("telegram_bot_token") };
}
export async function getLogoSettings(): Promise<LogoSettings> {
const [secretKey, publishableKey] = await Promise.all([
resolveValue("logo_dev_secret_key"),
resolveValue("logo_dev_publishable_key"),
]);
return { secretKey, publishableKey };
}
/** Status für die UI: Klartextwerte plus je Geheimnis nur ob gesetzt, nie der Wert. */
export async function getIntegrationStatus() {
const [smtp, telegram, logo] = await Promise.all([getSmtpSettings(), getTelegramSettings(), getLogoSettings()]);
return {
smtp: { host: smtp.host, port: smtp.port, user: smtp.user, from: smtp.from, passGesetzt: smtp.pass !== "" },
telegram: { botTokenGesetzt: telegram.botToken !== "" },
logo: { secretGesetzt: logo.secretKey !== "", publishableKey: logo.publishableKey },
verschluesselungBereit: canEncrypt(),
};
}
const SECTION_FIELDS: Record<IntegrationSection, string[]> = {
smtp: ["smtp_host", "smtp_port", "smtp_user", "smtp_pass", "mail_from"],
telegram: ["telegram_bot_token"],
logo: ["logo_dev_secret_key", "logo_dev_publishable_key"],
};
export function validateIntegrationSection(section: string, values: Record<string, unknown>): string[] {
if (!(INTEGRATION_SECTIONS as readonly string[]).includes(section)) return ["section"];
const errs: string[] = [];
if (section === "smtp") {
if (typeof values.smtp_port !== "undefined" && values.smtp_port !== "" && !Number.isInteger(Number(values.smtp_port))) {
errs.push("smtp_port");
}
if (typeof values.smtp_host === "string" && values.smtp_host.trim() !== "" && values.smtp_host.trim().length > 255) {
errs.push("smtp_host");
}
}
for (const key of SECTION_FIELDS[section as IntegrationSection]) {
const v = values[key];
if (typeof v !== "undefined" && typeof v !== "string") errs.push(key);
if (typeof v === "string" && v.length > 2000) errs.push(key);
}
return errs;
}
/**
* Speichert einen Bereich. Leere Geheimnis-Felder lassen den bestehenden Wert
* unverändert (UI sendet dann ""), alle anderen Felder werden übernommen
* ("" löscht einen Klartextwert). Gibt die gespeicherten Schlüssel zurück.
*/
export async function saveIntegrationSection(section: IntegrationSection, values: Record<string, string>): Promise<string[]> {
const db = getDb();
const saved: string[] = [];
for (const key of SECTION_FIELDS[section]) {
if (!(key in values)) continue;
const v = values[key];
if (SECRET_KEYS.has(key)) {
if (v === "") continue;
await db.appSetting.upsert({ where: { key }, create: { key, value: encryptSecret(v) }, update: { value: encryptSecret(v) } });
} else {
await db.appSetting.upsert({ where: { key }, create: { key, value: v }, update: { value: v } });
}
saved.push(key);
}
return saved;
}
+14 -10
View File
@@ -1,3 +1,5 @@
import { getLogoSettings } from "./integration-settings";
export type LogoCandidate = { name: string; domain: string; logoUrl: string | null }; export type LogoCandidate = { name: string; domain: string; logoUrl: string | null };
const DOMAIN_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/i; const DOMAIN_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/i;
@@ -10,11 +12,11 @@ export function normalizeDomain(domain: string | null | undefined): string | nul
} }
/** Baut die öffentliche Logo-URL für eine gespeicherte Domain. Key bleibt serverseitig. */ /** Baut die öffentliche Logo-URL für eine gespeicherte Domain. Key bleibt serverseitig. */
export function logoImageUrl(domain: string | null | undefined): string | null { export async function logoImageUrl(domain: string | null | undefined): Promise<string | null> {
const d = normalizeDomain(domain); const d = normalizeDomain(domain);
const token = process.env.LOGO_DEV_PUBLISHABLE_KEY; const { publishableKey } = await getLogoSettings();
if (!d || !token) return null; if (!d || !publishableKey) return null;
return `https://img.logo.dev/${d}?token=${token}&size=128&format=png`; return `https://img.logo.dev/${d}?token=${publishableKey}&size=128&format=png`;
} }
/** Kürzel aus dem Anbieternamen als Fallback, z. B. "Therme Erding" → "TE". */ /** Kürzel aus dem Anbieternamen als Fallback, z. B. "Therme Erding" → "TE". */
@@ -26,18 +28,20 @@ export function monogram(name: string): string {
} }
/** Reichert ein Voucher-Objekt um die öffentliche logoUrl an (serverseitig). */ /** Reichert ein Voucher-Objekt um die öffentliche logoUrl an (serverseitig). */
export function withLogoUrl<T extends { providerDomain: string | null }>(v: T): T & { logoUrl: string | null } { export async function withLogoUrl<T extends { providerDomain: string | null }>(
return { ...v, logoUrl: logoImageUrl(v.providerDomain) }; v: T
): Promise<T & { logoUrl: string | null }> {
return { ...v, logoUrl: await logoImageUrl(v.providerDomain) };
} }
/** Sucht Anbieter über Logo.dev (serverseitig, Secret-Key). Gibt max. 8 Treffer zurück. */ /** Sucht Anbieter über Logo.dev (serverseitig, Secret-Key). Gibt max. 8 Treffer zurück. */
export async function searchLogos(query: string): Promise<LogoCandidate[]> { export async function searchLogos(query: string): Promise<LogoCandidate[]> {
const key = process.env.LOGO_DEV_SECRET_KEY; const { secretKey } = await getLogoSettings();
if (!key) throw new Error("LOGO_DEV_SECRET_KEY fehlt"); if (!secretKey) throw new Error("LOGO_DEV_SECRET_KEY fehlt");
const q = query.trim(); const q = query.trim();
if (!q) return []; if (!q) return [];
const res = await fetch(`https://api.logo.dev/search?q=${encodeURIComponent(q)}`, { const res = await fetch(`https://api.logo.dev/search?q=${encodeURIComponent(q)}`, {
headers: { Authorization: `Bearer ${key}` }, headers: { Authorization: `Bearer ${secretKey}` },
}); });
if (!res.ok) throw new Error(`Logosuche ${res.status}`); if (!res.ok) throw new Error(`Logosuche ${res.status}`);
const data: unknown = await res.json(); const data: unknown = await res.json();
@@ -49,7 +53,7 @@ export async function searchLogos(query: string): Promise<LogoCandidate[]> {
const domain = normalizeDomain(typeof rec.domain === "string" ? rec.domain : null); const domain = normalizeDomain(typeof rec.domain === "string" ? rec.domain : null);
if (!domain) continue; if (!domain) continue;
const name = typeof rec.name === "string" && rec.name.trim() ? rec.name.trim() : domain; const name = typeof rec.name === "string" && rec.name.trim() ? rec.name.trim() : domain;
out.push({ name, domain, logoUrl: logoImageUrl(domain) }); out.push({ name, domain, logoUrl: await logoImageUrl(domain) });
if (out.length >= 8) break; if (out.length >= 8) break;
} }
return out; return out;
+6 -4
View File
@@ -1,10 +1,12 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { getSmtpSettings } from "./integration-settings";
export async function sendMail(to: string, subject: string, text: string): Promise<void> { export async function sendMail(to: string, subject: string, text: string): Promise<void> {
const smtp = await getSmtpSettings();
const t = nodemailer.createTransport({ const t = nodemailer.createTransport({
host: process.env.SMTP_HOST, host: smtp.host || undefined,
port: Number(process.env.SMTP_PORT ?? 587), port: smtp.port,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, auth: smtp.user || smtp.pass ? { user: smtp.user, pass: smtp.pass } : undefined,
}); });
await t.sendMail({ from: process.env.MAIL_FROM, to, subject, text }); await t.sendMail({ from: smtp.from || undefined, to, subject, text });
} }
+38
View File
@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
const PREFIX = "gcm1:";
function encryptionKey(): Buffer {
const secret = process.env.SETTINGS_ENCRYPTION_KEY ?? "";
if (secret.length < 32) {
throw new Error("SETTINGS_ENCRYPTION_KEY fehlt oder ist zu kurz (min. 32 Zeichen)");
}
return createHash("sha256").update(secret, "utf8").digest();
}
/** Verschlüsselt einen Geheimnis-Wert mit AES-256-GCM. Format: gcm1:<base64(iv+cipher+tag)> */
export function encryptSecret(plain: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return PREFIX + Buffer.concat([iv, tag, enc]).toString("base64");
}
/** Entschlüsselt einen mit encryptSecret erzeugten Wert. Wirft bei Manipulation/falschem Key. */
export function decryptSecret(stored: string): string {
if (!stored.startsWith(PREFIX)) throw new Error("Unbekanntes Geheimnis-Format");
const raw = Buffer.from(stored.slice(PREFIX.length), "base64");
if (raw.length < 12 + 16 + 1) throw new Error("Beschädigter Geheimnis-Wert");
const iv = raw.subarray(0, 12);
const tag = raw.subarray(12, 28);
const enc = raw.subarray(28);
const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
}
/** true, wenn ein Wert verschlüsselt ablegbar ist (Key vorhanden). */
export function canEncrypt(): boolean {
return (process.env.SETTINGS_ENCRYPTION_KEY ?? "").length >= 32;
}
+5 -3
View File
@@ -1,7 +1,9 @@
import { getTelegramSettings } from "./integration-settings";
export async function sendTelegram(chatId: string, text: string): Promise<void> { export async function sendTelegram(chatId: string, text: string): Promise<void> {
const token = process.env.TELEGRAM_BOT_TOKEN; const { botToken } = await getTelegramSettings();
if (!token) throw new Error("TELEGRAM_BOT_TOKEN fehlt"); if (!botToken) throw new Error("TELEGRAM_BOT_TOKEN fehlt");
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ chat_id: chatId, text }), body: JSON.stringify({ chat_id: chatId, text }),
+13
View File
@@ -16,6 +16,7 @@ model Voucher {
kategorie String? kategorie String?
notiz String? notiz String?
providerDomain String? providerDomain String?
createdBy String?
status String @default("aktiv") status String @default("aktiv")
contacts VoucherContact[] contacts VoucherContact[]
rules ReminderRule[] rules ReminderRule[]
@@ -56,3 +57,15 @@ model NotificationLog {
rule ReminderRule @relation(fields: [ruleId], references: [id], onDelete: Cascade) rule ReminderRule @relation(fields: [ruleId], references: [id], onDelete: Cascade)
@@unique([ruleId, kanal, empfaenger, zielDatum]) @@unique([ruleId, kanal, empfaenger, zielDatum])
} }
model AppSetting {
key String @id
value String
updatedAt DateTime @updatedAt
}
model HouseholdMember {
id Int @id @default(autoincrement())
name String @unique
createdAt DateTime @default(now())
}
+4 -4
View File
@@ -13,12 +13,12 @@ describe("logo", () => {
expect(normalizeDomain(" keinedomain ")).toBeNull(); expect(normalizeDomain(" keinedomain ")).toBeNull();
expect(normalizeDomain(null)).toBeNull(); expect(normalizeDomain(null)).toBeNull();
}); });
it("logoImageUrl braucht Domain und öffentlichen Key", () => { it("logoImageUrl braucht Domain und öffentlichen Key", async () => {
process.env.LOGO_DEV_PUBLISHABLE_KEY = "pk_test"; process.env.LOGO_DEV_PUBLISHABLE_KEY = "pk_test";
expect(logoImageUrl("stripe.com")).toBe("https://img.logo.dev/stripe.com?token=pk_test&size=128&format=png"); expect(await logoImageUrl("stripe.com")).toBe("https://img.logo.dev/stripe.com?token=pk_test&size=128&format=png");
expect(logoImageUrl(null)).toBeNull(); expect(await logoImageUrl(null)).toBeNull();
delete process.env.LOGO_DEV_PUBLISHABLE_KEY; delete process.env.LOGO_DEV_PUBLISHABLE_KEY;
expect(logoImageUrl("stripe.com")).toBeNull(); expect(await logoImageUrl("stripe.com")).toBeNull();
}); });
it("monogram bildet Kürzel aus dem Namen", () => { it("monogram bildet Kürzel aus dem Namen", () => {
expect(monogram("Therme Erding")).toBe("TE"); expect(monogram("Therme Erding")).toBe("TE");
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { canEncrypt, decryptSecret, encryptSecret } from "../lib/settings-crypto";
import { validateIntegrationSection } from "../lib/integration-settings";
const ORIGINAL = process.env.SETTINGS_ENCRYPTION_KEY;
beforeEach(() => {
process.env.SETTINGS_ENCRYPTION_KEY = "test-schluessel-mindestens-32-zeichen-lang!";
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.SETTINGS_ENCRYPTION_KEY;
else process.env.SETTINGS_ENCRYPTION_KEY = ORIGINAL;
});
describe("settings-crypto", () => {
it("roundtrip: entschlüsseln liefert Klartext", () => {
const enc = encryptSecret("smtp-geheim");
expect(enc.startsWith("gcm1:")).toBe(true);
expect(decryptSecret(enc)).toBe("smtp-geheim");
});
it("gleicher Klartext ergibt verschiedene Chiffre (Zufalls-IV)", () => {
expect(encryptSecret("x")).not.toBe(encryptSecret("x"));
});
it("manipulierter Wert wird abgelehnt", () => {
const enc = encryptSecret("wichtig");
const tampered = enc.slice(0, -2) + (enc.endsWith("AA") ? "BB" : "AA");
expect(() => decryptSecret(tampered)).toThrow();
expect(() => decryptSecret("klartext")).toThrow();
});
it("ohne/zu kurzem Key kein Verschlüsseln", () => {
delete process.env.SETTINGS_ENCRYPTION_KEY;
expect(canEncrypt()).toBe(false);
expect(() => encryptSecret("x")).toThrow("SETTINGS_ENCRYPTION_KEY");
process.env.SETTINGS_ENCRYPTION_KEY = "kurz";
expect(canEncrypt()).toBe(false);
});
});
describe("validateIntegrationSection", () => {
it("unbekannte Bereiche ablehnen", () => {
expect(validateIntegrationSection("login", {})).toEqual(["section"]);
});
it("gültige SMTP-Werte bestehen", () => {
expect(validateIntegrationSection("smtp", { smtp_host: "mail.de", smtp_port: "587", smtp_pass: "" })).toEqual([]);
});
it("ungültiger Port und Typen ablehnen", () => {
expect(validateIntegrationSection("smtp", { smtp_port: "abc" })).toEqual(["smtp_port"]);
expect(validateIntegrationSection("smtp", { smtp_host: 5 })).toEqual(["smtp_host"]);
});
it("leeres Geheimnis-Feld ist ok (behält bestehenden Wert)", () => {
expect(validateIntegrationSection("telegram", { telegram_bot_token: "" })).toEqual([]);
expect(validateIntegrationSection("logo", { logo_dev_secret_key: "sk_x", logo_dev_publishable_key: "pk_x" })).toEqual([]);
});
});