feat: verschlüsselte Integrationseinstellungen, Haushaltsmitglieder mit Ersteller, Backup-Download
This commit is contained in:
1 parent
a1195223e0
commit
267f4fcf70
21 files changed
+724
-30
No files matched your search
@@ -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=
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() });
|
||||||
|
}
|
||||||
@@ -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 }> }) {
|
||||||
|
|||||||
@@ -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
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 });
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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 }),
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user