feat: Einstellungsseite in Tabs gruppiert (Haushalt, Benachrichtigungen, Dienste, Daten & Backup)
This commit is contained in:
1 parent
1744120503
commit
384c0ee6b3
3 files changed
+424
-258
No files matched your search
+220
-195
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user