feat: Einstellungsseite in Tabs gruppiert (Haushalt, Benachrichtigungen, Dienste, Daten & Backup)

This commit is contained in:
opencode committed 2026-10-18 18:10:59 +02:00
1 parent 1744120503
commit 384c0ee6b3
3 files changed
+424 -258

No files matched your search

+220 -195
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
type Status = {
export type IntegrationStatus = {
smtp: { host: string; port: number; user: string; from: string; passGesetzt: boolean };
telegram: { botTokenGesetzt: boolean };
logo: { secretGesetzt: boolean; publishableKey: string };
@@ -13,7 +13,7 @@ type Status = {
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>) {
export async function saveSection(section: string, values: Record<string, string>) {
const res = await fetch("/api/settings/integrations", {
method: "POST",
headers: { "content-type": "application/json" },
@@ -21,16 +21,22 @@ async function saveSection(section: string, values: Record<string, string>) {
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message ?? `Fehler: ${(data.errors ?? []).join(", ")}`);
return data.status as Status;
return data.status as IntegrationStatus;
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
export function useIntegrationStatus() {
const [status, setStatus] = useState<IntegrationStatus | null>(null);
const [loadError, setLoadError] = useState("");
useEffect(() => {
fetch("/api/settings/integrations")
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("laden fehlgeschlagen"))))
.then((s: IntegrationStatus) => setStatus(s))
.catch(() => setLoadError("Einstellungen konnten nicht geladen werden."));
}, []);
return { status, setStatus, loadError };
}
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>
@@ -47,197 +53,216 @@ function SecretHint({ gesetzt }: { gesetzt: boolean }) {
);
}
export default function IntegrationsForm() {
const [status, setStatus] = useState<Status | null>(null);
const [loadError, setLoadError] = useState("");
function useNotice() {
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("");
const [oidcIssuer, setOidcIssuer] = useState("");
const [oidcClientId, setOidcClientId] = useState("");
const [oidcSecret, setOidcSecret] = 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);
setOidcIssuer(s.oidc.issuer);
setOidcClientId(s.oidc.clientId);
})
.catch(() => setLoadError("Einstellungen konnten nicht geladen werden."));
}, []);
async function onSave(section: "smtp" | "telegram" | "logo" | "oidc", 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);
}
if (section === "oidc") {
setOidcSecret("");
setOidcIssuer(s.oidc.issuer);
setOidcClientId(s.oidc.clientId);
}
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 { notice, setNotice };
}
export function SmtpSection({ status, onSaved }: { status: IntegrationStatus; onSaved: (s: IntegrationStatus) => void }) {
const { notice, setNotice } = useNotice();
const [smtp, setSmtp] = useState({ host: status.smtp.host, port: String(status.smtp.port), user: status.smtp.user, pass: "", from: status.smtp.from });
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>
<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>
<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>
<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>
<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>
<Section title="SSO (OIDC)">
<p className="text-[13px] text-muted">Generischer OIDC-Anbieter, z. B. Authentik oder Keycloak. Nach dem Speichern erscheint auf der Anmeldeseite „Mit SSO anmelden“.</p>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="oidc-issuer" className="block text-[13px] font-semibold">Issuer-URL</label>
<input
id="oidc-issuer"
value={oidcIssuer}
onChange={(e) => setOidcIssuer(e.target.value)}
autoComplete="off"
placeholder="https://login.beispiel.de/realms/haushalt"
className={inputClass}
/>
</div>
<div>
<label htmlFor="oidc-client" className="block text-[13px] font-semibold">Client-ID</label>
<input
id="oidc-client"
value={oidcClientId}
onChange={(e) => setOidcClientId(e.target.value)}
autoComplete="off"
placeholder="gutscheintracker"
className={inputClass}
/>
</div>
<div>
<label htmlFor="oidc-secret" className="block text-[13px] font-semibold">Client-Secret</label>
<input
id="oidc-secret"
type="password"
value={oidcSecret}
onChange={(e) => setOidcSecret(e.target.value)}
autoComplete="off"
placeholder={status.oidc.secretGesetzt ? "••••••••" : ""}
className={inputClass}
/>
<SecretHint gesetzt={status.oidc.secretGesetzt} />
</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>
<button
type="button"
onClick={() => onSave("oidc", { oidc_issuer: oidcIssuer, oidc_client_id: oidcClientId, oidc_client_secret: oidcSecret }, "OIDC-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>
<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={async () => {
setNotice("");
try {
const s = await saveSection("smtp", { smtp_host: smtp.host, smtp_port: smtp.port, smtp_user: smtp.user, smtp_pass: smtp.pass, mail_from: smtp.from });
setSmtp((v) => ({ ...v, pass: "" }));
onSaved(s);
setNotice("E-Mail-Einstellungen gespeichert.");
} catch (e) {
setNotice(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
}
}}
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>
{notice && <p role="status" className="mt-3 text-sm font-medium text-muted">{notice}</p>}
</Section>
);
}
{notice && <p role="status" className="text-sm font-medium text-muted">{notice}</p>}
</div>
export function TelegramSection({ status, onSaved }: { status: IntegrationStatus; onSaved: (s: IntegrationStatus) => void }) {
const { notice, setNotice } = useNotice();
const [token, setToken] = useState("");
return (
<Section title="Telegram">
<label htmlFor="tg-token" className="block text-[13px] font-semibold">Bot-Token</label>
<input
id="tg-token"
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
autoComplete="off"
placeholder={status.telegram.botTokenGesetzt ? "••••••••" : "123456:ABC-DEF…"}
className={inputClass}
/>
<SecretHint gesetzt={status.telegram.botTokenGesetzt} />
<button
type="button"
onClick={async () => {
setNotice("");
try {
const s = await saveSection("telegram", { telegram_bot_token: token });
setToken("");
onSaved(s);
setNotice("Telegram-Token gespeichert.");
} catch (e) {
setNotice(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
}
}}
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>
{notice && <p role="status" className="mt-3 text-sm font-medium text-muted">{notice}</p>}
</Section>
);
}
export function LogoSection({ status, onSaved }: { status: IntegrationStatus; onSaved: (s: IntegrationStatus) => void }) {
const { notice, setNotice } = useNotice();
const [secret, setSecret] = useState("");
const [pub, setPub] = useState(status.logo.publishableKey);
return (
<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={secret}
onChange={(e) => setSecret(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={pub}
onChange={(e) => setPub(e.target.value)}
autoComplete="off"
placeholder="pk_…"
className={inputClass}
/>
</div>
</div>
<button
type="button"
onClick={async () => {
setNotice("");
try {
const s = await saveSection("logo", { logo_dev_secret_key: secret, logo_dev_publishable_key: pub });
setSecret("");
onSaved(s);
setNotice("Logo.dev-Schlüssel gespeichert.");
} catch (e) {
setNotice(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
}
}}
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>
{notice && <p role="status" className="mt-3 text-sm font-medium text-muted">{notice}</p>}
</Section>
);
}
export function OidcSection({ status, onSaved }: { status: IntegrationStatus; onSaved: (s: IntegrationStatus) => void }) {
const { notice, setNotice } = useNotice();
const [issuer, setIssuer] = useState(status.oidc.issuer);
const [clientId, setClientId] = useState(status.oidc.clientId);
const [secret, setSecret] = useState("");
return (
<Section title="SSO (OIDC)">
<p className="text-[13px] text-muted">Generischer OIDC-Anbieter, z. B. Authentik oder Keycloak. Nach dem Speichern erscheint auf der Anmeldeseite „Mit SSO anmelden“.</p>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="oidc-issuer" className="block text-[13px] font-semibold">Issuer-URL</label>
<input
id="oidc-issuer"
value={issuer}
onChange={(e) => setIssuer(e.target.value)}
autoComplete="off"
placeholder="https://login.beispiel.de/realms/haushalt"
className={inputClass}
/>
</div>
<div>
<label htmlFor="oidc-client" className="block text-[13px] font-semibold">Client-ID</label>
<input
id="oidc-client"
value={clientId}
onChange={(e) => setClientId(e.target.value)}
autoComplete="off"
placeholder="gutscheintracker"
className={inputClass}
/>
</div>
<div>
<label htmlFor="oidc-secret" className="block text-[13px] font-semibold">Client-Secret</label>
<input
id="oidc-secret"
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
autoComplete="off"
placeholder={status.oidc.secretGesetzt ? "••••••••" : ""}
className={inputClass}
/>
<SecretHint gesetzt={status.oidc.secretGesetzt} />
</div>
</div>
<button
type="button"
onClick={async () => {
setNotice("");
try {
const s = await saveSection("oidc", { oidc_issuer: issuer, oidc_client_id: clientId, oidc_client_secret: secret });
setSecret("");
onSaved(s);
setNotice("OIDC-Einstellungen gespeichert.");
} catch (e) {
setNotice(e instanceof Error ? e.message : "Speichern fehlgeschlagen");
}
}}
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>
{notice && <p role="status" className="mt-3 text-sm font-medium text-muted">{notice}</p>}
</Section>
);
}
+13 -63
View File
@@ -3,21 +3,10 @@ import Shell from "../components/shell";
import { getDb } from "@/lib/db";
import { getSessionUser } from "@/lib/authz";
import { DEFAULT_TAGE_VORHER } from "@/lib/settings";
import { TestButtons } from "./test-buttons";
import IntegrationsForm from "./integrations-form";
import UsersSection from "./users";
import SettingsTabs from "./tabs";
export const dynamic = "force-dynamic";
function Card({ 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-2 text-sm text-muted">{children}</div>
</section>
);
}
export default async function SettingsPage() {
const db = getDb();
const me = await getSessionUser();
@@ -29,57 +18,18 @@ export default async function SettingsPage() {
← Zurück
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Einstellungen</h1>
<p className="mt-1 text-sm text-muted">
Erinnerungen, Benachrichtigungen und Empfänger.
{!isAdmin && " Verwaltung und Integrationen sind dem Admin vorbehalten."}
</p>
<div className="mt-5 grid gap-4">
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Kontakte</h2>
<p className="mt-1 text-sm text-muted">Wer soll an eure Gutscheine erinnert werden?</p>
<Link
href="/contacts"
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"
>
Kontakte verwalten
</Link>
</section>
{isAdmin && me && <UsersSection ownId={me.id} ownEmail={me.email} />}
<Card title="Standard-Erinnerungstage">
<p>Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.</p>
</Card>
{isAdmin && <IntegrationsForm />}
{isAdmin && <TestButtons />}
<Card title="Letzter Cron-Lauf">
{last ? (
<p>
{last.sentAt.toLocaleString("de-DE")} · {last.kanal} an {last.empfaenger} · {last.status}
{last.fehler ? ` · Fehler: ${last.fehler}` : ""}
</p>
) : (
<p>Noch keine Benachrichtigung versendet.</p>
)}
</Card>
{isAdmin && (
<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>
)}
{isAdmin && (
<Card title="Konfiguration (.env)">
<p>APP_USER, APP_PASSWORD_HASH, ADMIN_EMAIL, SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, DATABASE_URL, CRON_SECRET, APP_URL, ALLOW_PASSWORD_LOGIN. SMTP, Telegram, Logo.dev und OIDC 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>
<p className="mt-1 text-sm text-muted">Erinnerungen, Benachrichtigungen, Dienste und Daten.</p>
<SettingsTabs
isAdmin={!!isAdmin}
ownId={me?.id ?? null}
ownEmail={me?.email ?? null}
defaultReminderText={`Neue Gutscheine erhalten diese Erinnerungsregeln: ${DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.`}
lastCronText={
last
? `${last.sentAt.toLocaleDateString("de-DE")} · ${last.kanal} an ${last.empfaenger} · ${last.status}${last.fehler ? ` · Fehler: ${last.fehler}` : ""}`
: null
}
/>
</Shell>
);
}
+191
View File
@@ -0,0 +1,191 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import Link from "next/link";
import UsersSection from "./users";
import { TestButtons } from "./test-buttons";
import { SmtpSection, TelegramSection, LogoSection, OidcSection, useIntegrationStatus } from "./integrations-form";
export type SettingsTab = "haushalt" | "benachrichtigungen" | "dienste" | "daten";
const TABS: { key: SettingsTab; label: string; adminOnly: boolean }[] = [
{ key: "haushalt", label: "Haushalt", adminOnly: false },
{ key: "benachrichtigungen", label: "Benachrichtigungen", adminOnly: false },
{ key: "dienste", label: "Dienste", adminOnly: true },
{ key: "daten", label: "Daten & Backup", adminOnly: true },
];
export default function SettingsTabs({
isAdmin,
ownId,
ownEmail,
defaultReminderText,
lastCronText,
}: {
isAdmin: boolean;
ownId: number | null;
ownEmail: string | null;
defaultReminderText: string;
lastCronText: string | null;
}) {
return (
<Suspense>
<TabsInner
isAdmin={isAdmin}
ownId={ownId}
ownEmail={ownEmail}
defaultReminderText={defaultReminderText}
lastCronText={lastCronText}
/>
</Suspense>
);
}
function TabsInner({
isAdmin,
ownId,
ownEmail,
defaultReminderText,
lastCronText,
}: {
isAdmin: boolean;
ownId: number | null;
ownEmail: string | null;
defaultReminderText: string;
lastCronText: string | null;
}) {
const router = useRouter();
const params = useSearchParams();
const raw = params.get("tab");
const active: SettingsTab =
raw === "benachrichtigungen" || raw === "dienste" || raw === "daten" ? raw : "haushalt";
const visible = TABS.filter((t) => isAdmin || !t.adminOnly);
const shown = visible.some((t) => t.key === active) ? active : "haushalt";
function select(tab: SettingsTab) {
const q = new URLSearchParams(params.toString());
if (tab === "haushalt") q.delete("tab");
else q.set("tab", tab);
const s = q.toString();
router.replace(s ? `/settings?${s}` : "/settings", { scroll: false });
}
const { status, setStatus, loadError } = useIntegrationStatus();
const integrationsReady = isAdmin && status !== null;
return (
<div className="mt-5">
<div className="flex flex-wrap gap-2" role="tablist" aria-label="Einstellungsbereiche">
{visible.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={shown === t.key}
onClick={() => select(t.key)}
className={`rounded-full border px-4 py-2 text-[13px] font-semibold transition-colors ${
shown === t.key
? "border-brand-700 bg-brand-600 text-white"
: "border-line bg-white text-ink hover:border-brand-600 hover:text-brand-700"
}`}
>
{t.label}
</button>
))}
</div>
<div className="mt-4 grid gap-4" role="tabpanel">
{shown === "haushalt" && (
<>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Kontakte</h2>
<p className="mt-1 text-sm text-muted">Wer soll an eure Gutscheine erinnert werden?</p>
<Link
href="/contacts"
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"
>
Kontakte verwalten
</Link>
</section>
{isAdmin && ownId !== null && <UsersSection ownId={ownId} ownEmail={ownEmail} />}
</>
)}
{shown === "benachrichtigungen" && (
<>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Standard-Erinnerungstage</h2>
<p className="mt-1 text-sm text-muted">{defaultReminderText}</p>
</section>
{isAdmin ? (
<>
{loadError ? (
<p role="alert" className="text-sm font-medium text-danger">{loadError}</p>
) : !integrationsReady ? (
<p className="text-sm text-muted">Wird geladen …</p>
) : (
<>
<SmtpSection status={status} onSaved={setStatus} />
<TelegramSection status={status} onSaved={setStatus} />
</>
)}
<TestButtons />
</>
) : (
<p className="text-sm text-muted">SMTP und Telegram werden vom Admin eingerichtet.</p>
)}
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Letzter Cron-Lauf</h2>
<p className="mt-1 text-sm text-muted">{lastCronText ?? "Noch keine Benachrichtigung versendet."}</p>
</section>
</>
)}
{shown === "dienste" && isAdmin && (
<>
{loadError ? (
<p role="alert" className="text-sm font-medium text-danger">{loadError}</p>
) : !integrationsReady ? (
<p className="text-sm text-muted">Wird geladen …</p>
) : (
<>
<LogoSection status={status} onSaved={setStatus} />
<OidcSection status={status} onSaved={setStatus} />
</>
)}
</>
)}
{shown === "daten" && isAdmin && (
<>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Backup</h2>
<p className="mt-1 text-sm text-muted">
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 text-sm text-muted">
Wiederherstellen am Server: Datei als <code>/data/gutscheine.db</code> ablegen. Derselbe{" "}
<code>SETTINGS_ENCRYPTION_KEY</code> muss erhalten bleiben.
</p>
</section>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Konfiguration (.env)</h2>
<div className="mt-1 text-sm text-muted">
<p>APP_USER, APP_PASSWORD_HASH, ADMIN_EMAIL, SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, DATABASE_URL, CRON_SECRET, APP_URL, ALLOW_PASSWORD_LOGIN. SMTP, Telegram, Logo.dev und OIDC 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>
</div>
</section>
</>
)}
</div>
</div>
);
}