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
@@ -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 { DEFAULT_TAGE_VORHER } from "@/lib/settings";
|
||||
import { TestButtons } from "./test-buttons";
|
||||
import IntegrationsForm from "./integrations-form";
|
||||
import MembersSection from "./members";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -36,9 +38,11 @@ export default async function SettingsPage() {
|
||||
Kontakte verwalten
|
||||
</Link>
|
||||
</section>
|
||||
<MembersSection />
|
||||
<Card title="Standard-Erinnerungstage">
|
||||
<p>Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p>
|
||||
</Card>
|
||||
<IntegrationsForm />
|
||||
<TestButtons />
|
||||
<Card title="Letzter Cron-Lauf">
|
||||
{last ? (
|
||||
@@ -50,9 +54,20 @@ export default async function SettingsPage() {
|
||||
<p>Noch keine Benachrichtigung versendet.</p>
|
||||
)}
|
||||
</Card>
|
||||
<Card title="Konfiguration und 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 className="mt-2">SQLite-Datei sichern: Volume <code>gutschein-data</code> bzw. Datei <code>/data/gutscheine.db</code> regelmäßig kopieren.</p>
|
||||
<Card title="Backup">
|
||||
<p>Gesamte Datenbank als Datei sichern, inklusive Gutscheine, Kontakte, Mitglieder und verschlüsselte Einstellungen.</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>
|
||||
</div>
|
||||
</Shell>
|
||||
|
||||
Reference in new issue
Block a user