feat(admin): settings page with tabs, system checks and email test
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s

"Inhalte" becomes Admin -> Einstellungen, grouped into tabs so each
area stands on its own: Firma & Versand, Marke, Startseite, Seiten,
Social Media, Rechtstexte and System & Tests. Tabs are plain links
(?reiter=...), bookmarkable and usable without JavaScript; saving
returns to the same tab.

System & Tests brings everything that checks the running shop into one
place:
- integration overview (Stripe, Stripe webhook, Microsoft 365 mail,
  Microsoft sign-in, Turnstile, Google reviews, DHL, ApiCore): only
  whether each is set up and the names of missing variables, never a
  value; warns when Stripe runs on test keys or mixes test/live
- the email test, now with the IPv4 network checks for
  smtp.office365.com:587 and login.microsoftonline.com:443

/admin/inhalte and /admin/e-mail-test redirect to the new page; the
separate "E-Mail-Test" menu entry is gone. The tab parameter and the
saved group are validated against a fixed list before they are used in
a redirect. Docs point to the new location and cover the ENETUNREACH
(IPv6) case.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-27 00:07:34 +02:00
1 parent b5311fae83
commit de82ad0233
17 files changed
+654 -288

No files matched your search

+2 -2
View File
@@ -262,7 +262,7 @@ Die Antwort wird eine Stunde zwischengespeichert (`revalidate: 3600`), was die K
Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset gehen über **Microsoft 365 mit OAuth 2.0** (`src/lib/mail.ts`, `nodemailer`, SMTP mit XOAUTH2 an `smtp.office365.com:587`). Als App dient **dieselbe App-Registrierung wie „Mit Microsoft anmelden“** (`ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`) — eine Unternehmensanwendung statt zwei. Eine Anmeldung per Passwort (Basic Auth) gibt es nicht: kein Benutzerpasswort im Cluster, kein Konflikt mit MFA oder Sicherheitsstandards, nicht betroffen vom Ende der Basic Auth bei Exchange Online. Ohne `SMTP_USER` und die `ENTRA_*`-Werte wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt einrichten. Bestellbestätigung, Versand-/Abholbereit-Benachrichtigung, E-Mail-Verifizierung, Newsletter-Opt-in und Passwort-Reset gehen über **Microsoft 365 mit OAuth 2.0** (`src/lib/mail.ts`, `nodemailer`, SMTP mit XOAUTH2 an `smtp.office365.com:587`). Als App dient **dieselbe App-Registrierung wie „Mit Microsoft anmelden“** (`ENTRA_TENANT_ID`, `ENTRA_CLIENT_ID`, `ENTRA_CLIENT_SECRET`) — eine Unternehmensanwendung statt zwei. Eine Anmeldung per Passwort (Basic Auth) gibt es nicht: kein Benutzerpasswort im Cluster, kein Konflikt mit MFA oder Sicherheitsstandards, nicht betroffen vom Ende der Basic Auth bei Exchange Online. Ohne `SMTP_USER` und die `ENTRA_*`-Werte wird nicht gesendet, sondern nur geloggt — praktisch für lokale Entwicklung, aber vor Live-Gang unbedingt einrichten.
Ob der Versand funktioniert, zeigt **Admin → E-Mail-Test**: Token, Anmeldung, Test-Mail an die eigene Admin-Adresse und bei einem Fehler die Meldung von Microsoft samt Ursache. Ob der Versand funktioniert, zeigt **Admin → Einstellungen → System & Tests**: Token, Anmeldung, Test-Mail an die eigene Admin-Adresse und bei einem Fehler die Meldung von Microsoft samt Ursache.
**Einrichtung** (einmalig; die SSO-App-Registrierung aus `.env.example` muss schon bestehen): **Einrichtung** (einmalig; die SSO-App-Registrierung aus `.env.example` muss schon bestehen):
@@ -278,7 +278,7 @@ Ob der Versand funktioniert, zeigt **Admin → E-Mail-Test**: Token, Anmeldung,
Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false Set-CASMailbox -Identity "[email protected]" -SmtpClientAuthenticationDisabled $false
``` ```
Die letzte Zeile ist nötig, weil auch OAuth über „authentifiziertes SMTP“ läuft. Änderungen brauchen bis zu 30 Minuten. Die letzte Zeile ist nötig, weil auch OAuth über „authentifiziertes SMTP“ läuft. Änderungen brauchen bis zu 30 Minuten.
5. **Kubernetes:** `SMTP_USER` im Secret setzen; `SMTP_HOST`, `SMTP_PORT` und `SMTP_PASSWORD` aus bestehenden Secrets/ConfigMaps entfernen. Danach `kubectl rollout restart deploy/wabenhain-app -n wabenhain` und **Admin → E-Mail-Test**. 5. **Kubernetes:** `SMTP_USER` im Secret setzen; `SMTP_HOST`, `SMTP_PORT` und `SMTP_PASSWORD` aus bestehenden Secrets/ConfigMaps entfernen. Danach `kubectl rollout restart deploy/wabenhain-app -n wabenhain` und **Admin → Einstellungen → System & Tests**.
6. **Aufräumen:** Das bisher für SMTP genutzte Benutzerpasswort ändern; „Authentifiziertes SMTP“ bleibt nur für das Absender-Postfach an. 6. **Aufräumen:** Das bisher für SMTP genutzte Benutzerpasswort ändern; „Authentifiziertes SMTP“ bleibt nur für das Absender-Postfach an.
**Sicherheit:** Das SSO-Client-Secret darf damit auch senden — Exchange beschränkt das aber auf das eine freigegebene Postfach. Läuft das Secret ab, fallen **Anmeldung und Mailversand gleichzeitig** aus; der E-Mail-Test meldet dann „Das Client-Secret ist abgelaufen“ (AADSTS7000222): neuen Schlüssel anlegen, `ENTRA_CLIENT_SECRET` ersetzen, Pod neu starten. Ablaufdatum im Kalender eintragen. **Sicherheit:** Das SSO-Client-Secret darf damit auch senden — Exchange beschränkt das aber auf das eine freigegebene Postfach. Läuft das Secret ab, fallen **Anmeldung und Mailversand gleichzeitig** aus; der E-Mail-Test meldet dann „Das Client-Secret ist abgelaufen“ (AADSTS7000222): neuen Schlüssel anlegen, `ENTRA_CLIENT_SECRET` ersetzen, Pod neu starten. Ablaufdatum im Kalender eintragen.
+6 -5
View File
@@ -377,9 +377,9 @@ Der Reihe nach:
Jeder fehlende Pflichtwert erscheint als Blocker mit Begründung. Jeder fehlende Pflichtwert erscheint als Blocker mit Begründung.
2. **Shop erreichbar:** `https://wabenhain.com` öffnen. 2. **Shop erreichbar:** `https://wabenhain.com` öffnen.
3. **Admin-Login:** `https://wabenhain.com/konto` mit `ADMIN_EMAIL`/`ADMIN_PASSWORD` anmelden → Zwei-Faktor-Authentifizierung einrichten → Admin-Bereich öffnet sich. 3. **Admin-Login:** `https://wabenhain.com/konto` mit `ADMIN_EMAIL`/`ADMIN_PASSWORD` anmelden → Zwei-Faktor-Authentifizierung einrichten → Admin-Bereich öffnet sich.
4. **Mailversand:** **Admin → E-Mail-Test** → „Test-Mail senden“. Erfolg: grüne Meldung und eine Mail an die Admin-Adresse. Fehler: die Seite zeigt die Meldung von Microsoft und was zu tun ist (siehe [Fehlerbehebung](#11-fehlerbehebung)). 4. **Mailversand und Anbindungen:** **Admin → Einstellungen → System & Tests**. Oben steht, welche Dienste eingerichtet sind (fehlende Werte werden benannt). Darunter „Test-Mail senden“: zuerst prüft der Test, ob `smtp.office365.com:587` und `login.microsoftonline.com:443` per IPv4 erreichbar sind, dann den Versand. Erfolg: grüne Meldung und eine Mail an die Admin-Adresse. Fehler: die Seite zeigt die Meldung von Microsoft und was zu tun ist (siehe [Fehlerbehebung](#11-fehlerbehebung)).
5. **Microsoft-Anmeldung:** abmelden, auf `/konto` „Mit Microsoft anmelden“ wählen. 5. **Microsoft-Anmeldung:** abmelden, auf `/konto` „Mit Microsoft anmelden“ wählen.
6. **Firmendaten:** **Admin → Inhalte → Firmendaten** — Name, Anschrift und eine **gültige** E-Mail-Adresse eintragen. Sie erscheinen im Impressum und in der Widerrufsbelehrung. 6. **Firmendaten:** **Admin → Einstellungen → Firma & Versand** — Name, Anschrift und eine **gültige** E-Mail-Adresse eintragen. Sie erscheinen im Impressum und in der Widerrufsbelehrung.
7. **Bezahlung:** eine kleine echte Bestellung aufgeben, bezahlen, im Admin prüfen: Status „Bezahlt“, Rechnung vorhanden, Bestätigungsmail angekommen. Danach im Admin erstatten. 7. **Bezahlung:** eine kleine echte Bestellung aufgeben, bezahlen, im Admin prüfen: Status „Bezahlt“, Rechnung vorhanden, Bestätigungsmail angekommen. Danach im Admin erstatten.
8. **Widerruf:** im Footer „Vertrag widerrufen“ testen → Eingangsbestätigung kommt an, Eintrag erscheint unter **Admin → Widerrufe**. 8. **Widerruf:** im Footer „Vertrag widerrufen“ testen → Eingangsbestätigung kommt an, Eintrag erscheint unter **Admin → Widerrufe**.
@@ -395,13 +395,13 @@ Der Reihe nach:
| **DHL-/Google-/Turnstile-Schlüssel** | je nach Anbieter | Funktion ruht | Beim Anbieter erneuern, Secret ersetzen | | **DHL-/Google-/Turnstile-Schlüssel** | je nach Anbieter | Funktion ruht | Beim Anbieter erneuern, Secret ersetzen |
| **Postfach-Freigabe** | bei Umbenennung oder neuem Absender-Postfach | Mailversand scheitert („Exchange lässt die App nicht …“) | Abschnitt 4.2 für das neue Postfach wiederholen, `SMTP_USER` und `SMTP_FROM` anpassen | | **Postfach-Freigabe** | bei Umbenennung oder neuem Absender-Postfach | Mailversand scheitert („Exchange lässt die App nicht …“) | Abschnitt 4.2 für das neue Postfach wiederholen, `SMTP_USER` und `SMTP_FROM` anpassen |
Nach **jeder** Änderung an Secret oder ConfigMap: `kubectl rollout restart deployment/wabenhain-app -n wabenhain` und kurz **Admin → E-Mail-Test**. Nach **jeder** Änderung an Secret oder ConfigMap: `kubectl rollout restart deployment/wabenhain-app -n wabenhain` und kurz **Admin → Einstellungen → System & Tests**.
--- ---
## 11. Fehlerbehebung ## 11. Fehlerbehebung
### Mailversand (Anzeige in **Admin → E-Mail-Test**) ### Mailversand (Anzeige in **Admin → Einstellungen → System & Tests**)
| Meldung / Ursache | Lösung | | Meldung / Ursache | Lösung |
|---|---| |---|---|
@@ -413,7 +413,8 @@ Nach **jeder** Änderung an Secret oder ConfigMap: `kubectl rollout restart depl
| „Exchange lässt die App nicht mit dem Postfach senden“ (`535 5.7.3`) | (1) `SMTP.SendAsApp` erteilt? (3.3) (2) `New-ServicePrincipal` mit der Objekt-ID der **Unternehmensanwendung**? (3.4/4.2) (3) `Add-MailboxPermission` für genau `SMTP_USER`? (4) 30 Minuten gewartet? | | „Exchange lässt die App nicht mit dem Postfach senden“ (`535 5.7.3`) | (1) `SMTP.SendAsApp` erteilt? (3.3) (2) `New-ServicePrincipal` mit der Objekt-ID der **Unternehmensanwendung**? (3.4/4.2) (3) `Add-MailboxPermission` für genau `SMTP_USER`? (4) 30 Minuten gewartet? |
| „Authentifiziertes SMTP ausgeschaltet“ (`SmtpClientAuthentication is disabled`) | `Set-CASMailbox -Identity "hallo@wabenhain.com" -SmtpClientAuthenticationDisabled $false` | | „Authentifiziertes SMTP ausgeschaltet“ (`SmtpClientAuthentication is disabled`) | `Set-CASMailbox -Identity "hallo@wabenhain.com" -SmtpClientAuthenticationDisabled $false` |
| „SendAsDenied“ | `SMTP_FROM` ist nicht die Adresse aus `SMTP_USER` → angleichen | | „SendAsDenied“ | `SMTP_FROM` ist nicht die Adresse aus `SMTP_USER` → angleichen |
| „nicht erreichbar“ (Netzwerk) | Cluster muss ausgehend `smtp.office365.com:587` und `login.microsoftonline.com:443` erreichen (Firewall, NetworkPolicy) | | „nicht erreichbar“ (Netzwerk) | Die Netzwerkprüfung im Test zeigt, welcher Weg fehlt. Der Cluster muss ausgehend per IPv4 `smtp.office365.com:587` und `login.microsoftonline.com:443` erreichen (Firewall des Hosters, NetworkPolicy). Manche Hoster sperren Mail-Ports für neue Server — dort die Freigabe von Port 587 beantragen. |
| `ENETUNREACH 2603:…` (IPv6-Adresse) | Stammte aus älteren Versionen, die zufällig eine IPv6-Adresse wählten; der Shop verbindet sich inzwischen nur per IPv4. Tritt es trotzdem auf: aktuelles Image ausrollen. |
Ausführliche Fehler stehen außerdem im Pod-Log: Ausführliche Fehler stehen außerdem im Pod-Log:
+4 -3
View File
@@ -8,6 +8,7 @@ import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared"; import { requireAdmin } from "./_shared";
import { fail, succeed } from "@/lib/flash"; import { fail, succeed } from "@/lib/flash";
import { normalizeContactEmail } from "@/lib/contact-email"; import { normalizeContactEmail } from "@/lib/contact-email";
import { settingsPathForGroup } from "@/lib/settings-tabs";
const COMPANY_EMAIL_KEY = "company.email"; const COMPANY_EMAIL_KEY = "company.email";
@@ -16,7 +17,7 @@ const COMPANY_EMAIL_KEY = "company.email";
* — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action * — jede Gruppen-Form sendet nur ihre eigenen Feldnamen, diese Action
* verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht * verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht
* lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" / * lokalisierte Felder kommen als "{key}", lokalisierte als "de::{key}" /
* "en::{key}" (siehe admin/inhalte/page.tsx). * "en::{key}" (siehe admin/einstellungen/content-form.tsx).
*/ */
export async function updateSiteContentAction(formData: FormData): Promise<void> { export async function updateSiteContentAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
@@ -59,7 +60,7 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
// Impressum ein Kontaktweg, der ins Leere fuehrt. // Impressum ein Kontaktweg, der ins Leere fuehrt.
if (def.key === COMPANY_EMAIL_KEY && normalizeContactEmail(valueDe) === null) { if (def.key === COMPANY_EMAIL_KEY && normalizeContactEmail(valueDe) === null) {
return fail( return fail(
"/admin/inhalte", settingsPathForGroup(group),
"Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com." "Die Firmen-E-Mail ergibt keine gültige Adresse. Möglich sind z. B. [email protected], info [at] wabenhain.com oder info [á] wabenhain.com."
); );
} }
@@ -85,5 +86,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
}); });
revalidatePath("/", "layout"); revalidatePath("/", "layout");
return succeed("/admin/inhalte", "Inhalte gespeichert."); return succeed(settingsPathForGroup(group), "Einstellungen gespeichert.");
} }
+5 -15
View File
@@ -1,17 +1,7 @@
import { BackLink } from "@/components/back-link"; import { redirect } from "next/navigation";
import { AdminPageHeader } from "@/components/admin-list"; import { SETTINGS_PATH } from "@/lib/settings-tabs";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { SmtpTestPanel } from "./smtp-test-panel";
export default async function AdminEmailTestPage() { /** Der E-Mail-Test liegt jetzt in Admin → Einstellungen → System & Tests. */
const t = getDictionary(await getLocale()); export default function AdminEmailTestRedirect(): never {
return ( redirect(`${SETTINGS_PATH}?reiter=system`);
<div>
<BackLink href="/admin" label={t.adminWithdrawalsPage.backToDashboard} />
<AdminPageHeader title={t.adminEmailTestPage.title} />
<p className="mb-8 max-w-2xl text-sm text-ink/70">{t.adminEmailTestPage.intro}</p>
<SmtpTestPanel />
</div>
);
} }
@@ -0,0 +1,199 @@
import { prisma } from "@/lib/prisma";
import { AdminImageField } from "@/components/admin-image-field";
import { AdminVideoField } from "@/components/admin-video-field";
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
import { updateSiteContentAction } from "../actions";
import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config";
import { Button } from "@/components/ui";
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = {
text: 1,
textarea: 3,
// Auswahlfelder rendern ein <select> und nutzen ROWS_BY_TYPE nicht — Wert
// nur vorhanden, damit dieses Record vollstaendig bleibt.
select: 0,
"list-simple": 4,
"list-titled": 5,
"list-numbered": 6,
"richtext-sections": 14,
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
// weiter vollstaendig ist.
image: 0,
video: 0,
};
export type CurrentValues = Map<string, { valueDe: string; valueEn: string | null }>;
export async function loadCurrentValues(): Promise<CurrentValues> {
const rows = await prisma.siteContent.findMany();
return new Map(rows.map((r) => [r.key, { valueDe: r.valueDe, valueEn: r.valueEn }]));
}
/** Bild- und Video-Felder — auf jedem Reiter vor den Textfeldern (fieldsForGroup). */
function isImageOrVideoField(def: ContentFieldDef): boolean {
return def.type === "image" || def.type === "video";
}
function FieldEditor({
def,
row,
locale,
t,
}: {
def: ContentFieldDef;
row: { valueDe: string; valueEn: string | null } | undefined;
locale: Locale;
t: Dictionary;
}) {
const rows = ROWS_BY_TYPE[def.type];
const label = locale === "de" ? def.labelDe : def.labelEn;
if (!def.localized) {
if (def.type === "image") {
return <AdminImageField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "video") {
return <AdminVideoField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "select") {
// Ohne gepflegten Wert ist der Vorgabewert vorausgewaehlt — genau der
// gilt zur Laufzeit ohnehin, die Anzeige soll das nicht anders
// darstellen als die Wirklichkeit.
const current = row?.valueDe?.trim() ? row.valueDe : def.getDefault("de");
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<select
name={def.key}
defaultValue={current}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
>
{(def.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{locale === "de" ? option.labelDe : option.labelEn}
</option>
))}
</select>
</label>
);
}
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
{def.type === "textarea" ? (
<textarea
name={def.key}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={def.key}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
/>
)}
</label>
);
}
const isTextarea = def.type !== "text";
return (
<div className="border-t border-ink/10 pt-5 first:border-t-0 first:pt-0">
<p className="text-sm text-ink/70 mb-2">{label}</p>
<div className="grid sm:grid-cols-2 gap-4">
<label className="block text-xs">
<span className="text-ink/70">{t.adminContentPage.deLabel}</span>
{isTextarea ? (
<textarea
name={`de::${def.key}`}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={`de::${def.key}`}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
)}
</label>
<label className="block text-xs">
<span className="text-ink/70">{t.adminContentPage.enLabel}</span>
{isTextarea ? (
<textarea
name={`en::${def.key}`}
rows={rows}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={`en::${def.key}`}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
)}
</label>
</div>
</div>
);
}
/** Ein <form> je Inhaltsgruppe auf ihrem Reiter (admin/einstellungen/page.tsx).
* updateSiteContentAction liest pro Absenden nur die in DIESEM Formular
* vorhandenen Registry-Felder (siehe admin/actions/content.ts) und springt
* danach ueber das mitgeschickte group="..." auf denselben Reiter zurueck. */
export function GroupForm({
groupHeading,
fields,
current,
groupKeyValue,
locale,
t,
}: {
groupHeading: string;
fields: ContentFieldDef[];
current: CurrentValues;
groupKeyValue: string;
locale: Locale;
t: Dictionary;
}) {
if (fields.length === 0) return null;
return (
<form
action={updateSiteContentAction}
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
>
<input type="hidden" name="group" value={groupKeyValue} />
<h3 className="text-lg text-heading">{groupHeading}</h3>
{fields.map((def) => (
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
))}
<Button type="submit" className="px-5 py-2.5">
{t.adminContentPage.save}
</Button>
</form>
);
}
/** Alle Felder einer Inhaltsgruppe, Bilder und Video zuerst — so steht auf jedem Reiter das Visuelle oben. */
export function fieldsForGroup(group: ContentFieldDef["group"]): ContentFieldDef[] {
const fields = CONTENT_REGISTRY.filter((f) => f.group === group);
return [...fields.filter(isImageOrVideoField), ...fields.filter((f) => !isImageOrVideoField(f))];
}
+142
View File
@@ -0,0 +1,142 @@
import Link from "next/link";
import { BackLink } from "@/components/back-link";
import { AdminBadge } from "@/components/admin-list";
import type { ContentFieldDef } from "@/lib/content-registry";
import { readIntegrationStatus } from "@/lib/integration-status";
import { SETTINGS_PATH, SETTINGS_TABS, parseSettingsTab, type SettingsTabId } from "@/lib/settings-tabs";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { Dictionary } from "@/i18n/dictionary-type";
import { GroupForm, fieldsForGroup, loadCurrentValues } from "./content-form";
import { SmtpTestPanel } from "./smtp-test-panel";
/**
* Alle Einstellungen des Shops an einem Ort, nach Bereichen in Reiter
* gegliedert (lib/settings-tabs.ts). Die Reiter sind echte Links
* (?reiter=…): direkt aufrufbar, als Lesezeichen speicherbar und ohne
* JavaScript bedienbar. Frueher getrennt: /admin/inhalte und
* /admin/e-mail-test — beide leiten hierher um.
*/
export default async function AdminEinstellungenPage(props: PageProps<"/admin/einstellungen">) {
const locale = await getLocale();
const t = getDictionary(locale);
const s = t.adminContentPage;
const { reiter } = await props.searchParams;
const activeId = parseSettingsTab(reiter);
const active = SETTINGS_TABS.find((tab) => tab.id === activeId) ?? SETTINGS_TABS[0];
const tabLabels: Record<SettingsTabId, string> = {
firma: s.tabCompany,
marke: s.tabBrand,
startseite: s.tabHome,
seiten: s.tabPages,
social: s.tabSocial,
recht: s.tabLegal,
system: s.tabSystem,
};
const groupHeadings: Record<ContentFieldDef["group"], string> = {
company: s.groupCompany,
shipping: s.groupShipping,
brand: s.groupBrand,
home: s.groupHome,
pages: s.groupPages,
social: s.groupSocial,
legal: s.groupLegal,
};
const current = active.groups.length > 0 ? await loadCurrentValues() : null;
return (
<div>
<BackLink href="/admin" label={s.backToDashboard} />
<h1 className="mb-6 text-2xl text-heading">{s.title}</h1>
<nav aria-label={s.tabsLabel} className="-mx-1 mb-8 overflow-x-auto border-b border-ink/10">
<ul className="flex min-w-max gap-1 px-1">
{SETTINGS_TABS.map((tab) => {
const isActive = tab.id === active.id;
return (
<li key={tab.id}>
<Link
href={`${SETTINGS_PATH}?reiter=${tab.id}`}
aria-current={isActive ? "page" : undefined}
className={`-mb-px block border-b-2 px-4 py-3 text-sm transition-colors ${
isActive ? "border-honey text-heading" : "border-transparent text-ink/65 hover:border-ink/20 hover:text-heading"
}`}
>
{tabLabels[tab.id]}
</Link>
</li>
);
})}
</ul>
</nav>
{active.id === "system" ? (
<SystemTab t={t} />
) : (
<>
<p className="mb-8 max-w-2xl text-sm text-ink/65">{s.intro}</p>
{current
? active.groups.map((group) => (
<GroupForm
key={group}
groupHeading={groupHeadings[group]}
groupKeyValue={group}
fields={fieldsForGroup(group)}
current={current}
locale={locale}
t={t}
/>
))
: null}
</>
)}
</div>
);
}
function SystemTab({ t }: { t: Dictionary }) {
const s = t.adminContentPage;
const integrations = readIntegrationStatus();
const warnings = { "stripe-test-mode": s.warningStripeTest, "stripe-mixed-mode": s.warningStripeMixed } as const;
return (
<div className="space-y-12">
<p className="max-w-2xl text-sm text-ink/65">{s.systemIntro}</p>
<section aria-labelledby="settings-integrations">
<h2 id="settings-integrations" className="text-xl text-heading">
{s.integrationsHeading}
</h2>
<p className="mb-5 mt-1 max-w-2xl text-sm text-ink/65">{s.integrationsIntro}</p>
<ul className="max-w-3xl divide-y divide-ink/10 border-y border-ink/10">
{integrations.map((integration) => (
<li key={integration.key} className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2 py-4">
<div className="min-w-0">
<p className="text-heading">{s.integrationNames[integration.key]}</p>
<p className="mt-0.5 text-xs text-ink/60">{integration.required ? s.statusRequired : s.statusOptional}</p>
{integration.warning ? (
<p className="mt-2 text-sm text-red-700 dark:text-red-300">{warnings[integration.warning]}</p>
) : null}
{!integration.configured ? (
<p className="mt-2 break-words font-mono text-xs text-ink/70">{s.statusMissing(integration.missing.join(", "))}</p>
) : null}
</div>
<AdminBadge tone={integration.configured ? (integration.warning ? "danger" : "positive") : integration.required ? "danger" : "neutral"}>
{integration.configured ? s.statusConfigured : s.statusNotConfigured}
</AdminBadge>
</li>
))}
</ul>
</section>
<section aria-labelledby="settings-email-test">
<h2 id="settings-email-test" className="text-xl text-heading">
{s.emailTestHeading}
</h2>
<p className="mb-5 mt-1 max-w-2xl text-sm text-ink/65">{t.adminEmailTestPage.intro}</p>
<SmtpTestPanel />
</section>
</div>
);
}
@@ -3,7 +3,7 @@
import { useActionState } from "react"; import { useActionState } from "react";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import { useI18n } from "@/i18n/i18n-provider"; import { useI18n } from "@/i18n/i18n-provider";
import { runSmtpTestAction, type SmtpTestState } from "./actions"; import { runSmtpTestAction, type SmtpTestState } from "./smtp-test-actions";
export function SmtpTestPanel() { export function SmtpTestPanel() {
const { t } = useI18n(); const { t } = useI18n();
@@ -52,6 +52,25 @@ export function SmtpTestPanel() {
</div> </div>
</div> </div>
) : null} ) : null}
{state && "network" in state ? (
<div className="mt-6 text-sm">
<p className="text-xs tracking-wide-caps text-ink/60">{s.networkHeading}</p>
<ul className="mt-2 space-y-1.5">
{[
{ label: s.networkSmtp, check: state.network.smtp },
{ label: s.networkEntra, check: state.network.entra },
].map(({ label, check }) => (
<li key={label} className="flex flex-wrap items-baseline gap-x-3">
<span className="text-ink/80">{label}:</span>
<span className={check.ok ? "text-heading" : "font-medium text-red-700 dark:text-red-300"}>
{check.ok ? s.networkOk : s.networkFailed}
</span>
<span className="font-mono text-xs text-ink/60">{check.detail}</span>
</li>
))}
</ul>
</div>
) : null}
</div> </div>
</div> </div>
); );
+5 -253
View File
@@ -1,255 +1,7 @@
import { prisma } from "@/lib/prisma"; import { redirect } from "next/navigation";
import { BackLink } from "@/components/back-link"; import { SETTINGS_PATH } from "@/lib/settings-tabs";
import { AdminImageField } from "@/components/admin-image-field";
import { AdminVideoField } from "@/components/admin-video-field";
import { CONTENT_REGISTRY, type ContentFieldDef } from "@/lib/content-registry";
import { updateSiteContentAction } from "../actions";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import type { Dictionary } from "@/i18n/dictionary-type";
import type { Locale } from "@/i18n/config";
import { Button } from "@/components/ui";
const ROWS_BY_TYPE: Record<ContentFieldDef["type"], number> = { /** Frueher "Inhalte" — jetzt Teil von Admin → Einstellungen. Alte Lesezeichen fuehren weiter. */
text: 1, export default function AdminInhalteRedirect(): never {
textarea: 3, redirect(SETTINGS_PATH);
// Auswahlfelder rendern ein <select> und nutzen ROWS_BY_TYPE nicht — Wert
// nur vorhanden, damit dieses Record vollstaendig bleibt.
select: 0,
"list-simple": 4,
"list-titled": 5,
"list-numbered": 6,
"richtext-sections": 14,
// Bild-/Video-Felder haben kein <textarea>, nutzen ROWS_BY_TYPE also nicht
// — Werte nur vorhanden, damit dieses Record<ContentFieldDef["type"], number>
// weiter vollstaendig ist.
image: 0,
video: 0,
};
type CurrentValues = Map<string, { valueDe: string; valueEn: string | null }>;
async function loadCurrentValues(): Promise<CurrentValues> {
const rows = await prisma.siteContent.findMany();
return new Map(rows.map((r) => [r.key, { valueDe: r.valueDe, valueEn: r.valueEn }]));
}
/** Bild- und Video-Felder gehoeren zum "Bilder"-Bereich, alles andere
* (Text/Listen/Rechtstexte) zum "Text"-Bereich — siehe AdminInhaltePage. */
function isImageOrVideoField(def: ContentFieldDef): boolean {
return def.type === "image" || def.type === "video";
}
function FieldEditor({
def,
row,
locale,
t,
}: {
def: ContentFieldDef;
row: { valueDe: string; valueEn: string | null } | undefined;
locale: Locale;
t: Dictionary;
}) {
const rows = ROWS_BY_TYPE[def.type];
const label = locale === "de" ? def.labelDe : def.labelEn;
if (!def.localized) {
if (def.type === "image") {
return <AdminImageField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "video") {
return <AdminVideoField name={def.key} label={label} initialValue={row?.valueDe ?? ""} />;
}
if (def.type === "select") {
// Ohne gepflegten Wert ist der Vorgabewert vorausgewaehlt — genau der
// gilt zur Laufzeit ohnehin, die Anzeige soll das nicht anders
// darstellen als die Wirklichkeit.
const current = row?.valueDe?.trim() ? row.valueDe : def.getDefault("de");
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
<select
name={def.key}
defaultValue={current}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
>
{(def.options ?? []).map((option) => (
<option key={option.value} value={option.value}>
{locale === "de" ? option.labelDe : option.labelEn}
</option>
))}
</select>
</label>
);
}
return (
<label className="block text-sm">
<span className="text-ink/70">{label}</span>
{def.type === "textarea" ? (
<textarea
name={def.key}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={def.key}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-heading"
/>
)}
</label>
);
}
const isTextarea = def.type !== "text";
return (
<div className="border-t border-ink/10 pt-5 first:border-t-0 first:pt-0">
<p className="text-sm text-ink/70 mb-2">{label}</p>
<div className="grid sm:grid-cols-2 gap-4">
<label className="block text-xs">
<span className="text-ink/70">{t.adminContentPage.deLabel}</span>
{isTextarea ? (
<textarea
name={`de::${def.key}`}
rows={rows}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={`de::${def.key}`}
defaultValue={row?.valueDe ?? ""}
placeholder={def.getDefault("de")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
)}
</label>
<label className="block text-xs">
<span className="text-ink/70">{t.adminContentPage.enLabel}</span>
{isTextarea ? (
<textarea
name={`en::${def.key}`}
rows={rows}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
) : (
<input
type="text"
name={`en::${def.key}`}
defaultValue={row?.valueEn ?? ""}
placeholder={def.getDefault("en")}
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 text-sm focus:outline-none focus:border-heading"
/>
)}
</label>
</div>
</div>
);
}
/** Ein <form> pro Gruppe UND Bereich (Bilder/Text) — siehe AdminInhaltePage.
* Zwei Formulare koennen dieselbe group="..." fuers Audit-Log/Reload
* mitschicken, das ist unproblematisch: updateSiteContentAction liest pro
* Absenden nur die tatsaechlich in DIESEM Formular vorhandenen Registry-
* Felder (siehe admin/actions/content.ts), Felder aus dem jeweils anderen
* Formular fehlen im FormData schlicht und werden uebersprungen. */
function GroupForm({
groupHeading,
fields,
current,
groupKeyValue,
locale,
t,
}: {
groupHeading: string;
fields: ContentFieldDef[];
current: CurrentValues;
groupKeyValue: string;
locale: Locale;
t: Dictionary;
}) {
if (fields.length === 0) return null;
return (
<form
action={updateSiteContentAction}
className="mb-10 bg-background border border-ink/10 p-6 space-y-6"
>
<input type="hidden" name="group" value={groupKeyValue} />
<h3 className="text-lg text-heading">{groupHeading}</h3>
{fields.map((def) => (
<FieldEditor key={def.key} def={def} row={current.get(def.key)} locale={locale} t={t} />
))}
<Button type="submit" className="px-5 py-2.5">
{t.adminContentPage.save}
</Button>
</form>
);
}
export default async function AdminInhaltePage() {
const locale = await getLocale();
const t = getDictionary(locale);
const current = await loadCurrentValues();
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
{ key: "brand", heading: t.adminContentPage.groupBrand },
{ key: "company", heading: t.adminContentPage.groupCompany },
{ key: "shipping", heading: t.adminContentPage.groupShipping },
{ key: "home", heading: t.adminContentPage.groupHome },
{ key: "pages", heading: t.adminContentPage.groupPages },
{ key: "social", heading: t.adminContentPage.groupSocial },
{ key: "legal", heading: t.adminContentPage.groupLegal },
];
return (
<div>
<BackLink href="/admin" label={t.adminContentPage.backToDashboard} />
<h1 className="text-2xl text-heading mb-2">{t.adminContentPage.title}</h1>
<p className="text-sm text-ink/65 mb-8 max-w-2xl">{t.adminContentPage.intro}</p>
<section className="mb-12">
<h2 className="text-xl text-heading mb-1">{t.adminContentPage.sectionImages}</h2>
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionImagesIntro}</p>
{groups.map((group) => (
<GroupForm
key={`images-${group.key}`}
groupHeading={group.heading}
groupKeyValue={group.key}
fields={CONTENT_REGISTRY.filter((f) => f.group === group.key && isImageOrVideoField(f))}
current={current}
locale={locale}
t={t}
/>
))}
</section>
<section>
<h2 className="text-xl text-heading mb-1">{t.adminContentPage.sectionText}</h2>
<p className="text-sm text-ink/65 mb-6 max-w-2xl">{t.adminContentPage.sectionTextIntro}</p>
{groups.map((group) => (
<GroupForm
key={`text-${group.key}`}
groupHeading={group.heading}
groupKeyValue={group.key}
fields={CONTENT_REGISTRY.filter((f) => f.group === group.key && !isImageOrVideoField(f))}
current={current}
locale={locale}
t={t}
/>
))}
</section>
</div>
);
} }
+1 -2
View File
@@ -57,9 +57,8 @@ function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavIte
{ {
label: t.adminShell.groupSite, label: t.adminShell.groupSite,
items: [ items: [
{ href: "/admin/inhalte", label: t.adminShell.navContent }, { href: "/admin/einstellungen", label: t.adminShell.navContent },
{ href: "/admin/audit-log", label: t.adminShell.navAuditLog }, { href: "/admin/audit-log", label: t.adminShell.navAuditLog },
{ href: "/admin/e-mail-test", label: t.adminShell.navEmailTest },
], ],
}, },
]; ];
+36 -3
View File
@@ -1269,6 +1269,11 @@ export const de: Dictionary = {
detailLabel: "Meldung des Mailservers", detailLabel: "Meldung des Mailservers",
configLabel: "Verwendete Einstellungen", configLabel: "Verwendete Einstellungen",
authModeOAuth: "Microsoft 365, OAuth 2.0 über die SSO-App", authModeOAuth: "Microsoft 365, OAuth 2.0 über die SSO-App",
networkHeading: "Netzwerk (IPv4)",
networkSmtp: "smtp.office365.com, Port 587",
networkEntra: "login.microsoftonline.com, Port 443",
networkOk: "erreichbar",
networkFailed: "nicht erreichbar",
missingLabel: "Fehlende Einstellungen", missingLabel: "Fehlende Einstellungen",
}, },
adminWithdrawalsPage: { adminWithdrawalsPage: {
@@ -1442,8 +1447,37 @@ export const de: Dictionary = {
chartTooltip: (day, value) => `${day}: ${value}`, chartTooltip: (day, value) => `${day}: ${value}`,
}, },
adminContentPage: { adminContentPage: {
tabsLabel: "Bereiche der Einstellungen",
tabCompany: "Firma & Versand",
tabBrand: "Marke",
tabHome: "Startseite",
tabPages: "Seiten",
tabSocial: "Social Media",
tabLegal: "Rechtstexte",
tabSystem: "System & Tests",
systemIntro: "Prüft, ob der Shop mit allen angebundenen Diensten arbeiten kann. Werte aus dem Kubernetes-Secret werden hier nie angezeigt, nur ob sie gesetzt sind.",
emailTestHeading: "E-Mail-Versand",
integrationsHeading: "Anbindungen",
integrationsIntro: "Welche Dienste eingerichtet sind. Fehlt etwas, stehen die Namen der fehlenden Werte daneben — einzutragen im Kubernetes-Secret bzw. in der ConfigMap (siehe docs/EINRICHTUNG.md).",
statusConfigured: "Eingerichtet",
statusNotConfigured: "Nicht eingerichtet",
statusMissing: (names) => `Fehlt: ${names}`,
statusRequired: "Pflicht",
statusOptional: "Optional",
warningStripeTest: "Testschlüssel aktiv — Bestellungen sehen erfolgreich aus, es fließt aber kein Geld.",
warningStripeMixed: "Geheimer und öffentlicher Schlüssel stammen aus verschiedenen Modi (test/live) — die Kasse funktioniert so nicht.",
integrationNames: {
stripe: "Stripe (Zahlung)",
stripeWebhook: "Stripe-Webhook (Zahlungsbestätigung)",
mail: "E-Mail-Versand (Microsoft 365)",
sso: "Mit Microsoft anmelden",
turnstile: "Cloudflare Turnstile (CAPTCHA)",
googleReviews: "Google-Rezensionen",
dhl: "DHL (Versandmarken)",
apicore: "ApiCore-Synchronisierung",
},
backToDashboard: "Zurück zum Dashboard", backToDashboard: "Zurück zum Dashboard",
title: "Inhalte", title: "Einstellungen",
intro: intro:
"Diese Texte erscheinen auf der Website. Ein leeres Feld bedeutet nicht \"leer auf der Seite\": Dann wird der ursprüngliche Text weiter angezeigt. Trage nur ein, was du ändern möchtest.", "Diese Texte erscheinen auf der Website. Ein leeres Feld bedeutet nicht \"leer auf der Seite\": Dann wird der ursprüngliche Text weiter angezeigt. Trage nur ein, was du ändern möchtest.",
sectionImages: "Bilder", sectionImages: "Bilder",
@@ -1510,8 +1544,7 @@ export const de: Dictionary = {
navVouchers: "Gutscheine", navVouchers: "Gutscheine",
navReviews: "Bewertungen", navReviews: "Bewertungen",
navAuditLog: "Audit-Log", navAuditLog: "Audit-Log",
navEmailTest: "E-Mail-Test", navContent: "Einstellungen",
navContent: "Inhalte",
navAriaLabel: "Admin-Navigation", navAriaLabel: "Admin-Navigation",
mobileNavAriaLabel: "Mobile Admin-Navigation", mobileNavAriaLabel: "Mobile Admin-Navigation",
openMenu: "Menü öffnen", openMenu: "Menü öffnen",
+36 -3
View File
@@ -1243,6 +1243,11 @@ export const en: Dictionary = {
detailLabel: "Mail server message", detailLabel: "Mail server message",
configLabel: "Settings used", configLabel: "Settings used",
authModeOAuth: "Microsoft 365, OAuth 2.0 via the SSO app", authModeOAuth: "Microsoft 365, OAuth 2.0 via the SSO app",
networkHeading: "Network (IPv4)",
networkSmtp: "smtp.office365.com, port 587",
networkEntra: "login.microsoftonline.com, port 443",
networkOk: "reachable",
networkFailed: "not reachable",
missingLabel: "Missing settings", missingLabel: "Missing settings",
}, },
adminWithdrawalsPage: { adminWithdrawalsPage: {
@@ -1416,8 +1421,37 @@ export const en: Dictionary = {
chartTooltip: (day, value) => `${day}: ${value}`, chartTooltip: (day, value) => `${day}: ${value}`,
}, },
adminContentPage: { adminContentPage: {
tabsLabel: "Settings sections",
tabCompany: "Company & shipping",
tabBrand: "Brand",
tabHome: "Home page",
tabPages: "Pages",
tabSocial: "Social media",
tabLegal: "Legal texts",
tabSystem: "System & tests",
systemIntro: "Checks whether the shop can work with all connected services. Values from the Kubernetes secret are never shown here, only whether they are set.",
emailTestHeading: "Email delivery",
integrationsHeading: "Integrations",
integrationsIntro: "Which services are set up. If something is missing, the names of the missing values are listed — to be added to the Kubernetes secret or config map (see docs/EINRICHTUNG.md).",
statusConfigured: "Set up",
statusNotConfigured: "Not set up",
statusMissing: (names) => `Missing: ${names}`,
statusRequired: "Required",
statusOptional: "Optional",
warningStripeTest: "Test keys active — orders look successful, but no money is collected.",
warningStripeMixed: "Secret and publishable key come from different modes (test/live) — checkout will not work like this.",
integrationNames: {
stripe: "Stripe (payments)",
stripeWebhook: "Stripe webhook (payment confirmation)",
mail: "Email delivery (Microsoft 365)",
sso: "Sign in with Microsoft",
turnstile: "Cloudflare Turnstile (CAPTCHA)",
googleReviews: "Google reviews",
dhl: "DHL (shipping labels)",
apicore: "ApiCore sync",
},
backToDashboard: "Back to dashboard", backToDashboard: "Back to dashboard",
title: "Content", title: "Settings",
intro: intro:
"These texts appear on the website. An empty field does not mean \"blank on the page\": the original text keeps showing. Only fill in what you want to change.", "These texts appear on the website. An empty field does not mean \"blank on the page\": the original text keeps showing. Only fill in what you want to change.",
sectionImages: "Images", sectionImages: "Images",
@@ -1484,8 +1518,7 @@ export const en: Dictionary = {
navVouchers: "Vouchers", navVouchers: "Vouchers",
navReviews: "Reviews", navReviews: "Reviews",
navAuditLog: "Audit log", navAuditLog: "Audit log",
navEmailTest: "Email test", navContent: "Settings",
navContent: "Content",
navAriaLabel: "Admin navigation", navAriaLabel: "Admin navigation",
mobileNavAriaLabel: "Mobile admin navigation", mobileNavAriaLabel: "Mobile admin navigation",
openMenu: "Open menu", openMenu: "Open menu",
+25 -1
View File
@@ -830,6 +830,11 @@ export interface Dictionary {
detailLabel: string; detailLabel: string;
configLabel: string; configLabel: string;
authModeOAuth: string; authModeOAuth: string;
networkHeading: string;
networkSmtp: string;
networkEntra: string;
networkOk: string;
networkFailed: string;
missingLabel: string; missingLabel: string;
}; };
adminWithdrawalsPage: { adminWithdrawalsPage: {
@@ -996,6 +1001,26 @@ export interface Dictionary {
chartTooltip: (day: string, value: string) => string; chartTooltip: (day: string, value: string) => string;
}; };
adminContentPage: { adminContentPage: {
tabsLabel: string;
tabCompany: string;
tabBrand: string;
tabHome: string;
tabPages: string;
tabSocial: string;
tabLegal: string;
tabSystem: string;
systemIntro: string;
emailTestHeading: string;
integrationsHeading: string;
integrationsIntro: string;
statusConfigured: string;
statusNotConfigured: string;
statusMissing: (names: string) => string;
statusRequired: string;
statusOptional: string;
warningStripeTest: string;
warningStripeMixed: string;
integrationNames: Record<"stripe" | "stripeWebhook" | "mail" | "sso" | "turnstile" | "googleReviews" | "dhl" | "apicore", string>;
backToDashboard: string; backToDashboard: string;
title: string; title: string;
intro: string; intro: string;
@@ -1059,7 +1084,6 @@ export interface Dictionary {
navVouchers: string; navVouchers: string;
navReviews: string; navReviews: string;
navAuditLog: string; navAuditLog: string;
navEmailTest: string;
navContent: string; navContent: string;
navAriaLabel: string; navAriaLabel: string;
mobileNavAriaLabel: string; mobileNavAriaLabel: string;
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { readIntegrationStatus } from "./integration-status";
const FULL = {
STRIPE_SECRET_KEY: "sk_live_abc",
STRIPE_PUBLISHABLE_KEY: "pk_live_abc",
STRIPE_WEBHOOK_SECRET: "whsec_abc",
SMTP_USER: "[email protected]",
ENTRA_TENANT_ID: "t",
ENTRA_CLIENT_ID: "c",
ENTRA_CLIENT_SECRET: "s",
ENTRA_REDIRECT_URI: "https://wabenhain.com/api/auth/entra/callback",
TURNSTILE_SECRET_KEY: "x",
NEXT_PUBLIC_TURNSTILE_SITE_KEY: "y",
GOOGLE_PLACES_API_KEY: "g",
GOOGLE_PLACE_ID: "p",
DHL_API_KEY: "a",
DHL_API_SECRET: "b",
DHL_GKP_USERNAME: "u",
DHL_GKP_PASSWORD: "w",
DHL_ACCOUNT_NUMBER: "12345678901234",
APICORE_API_URL: "https://api.example.org/api",
APICORE_SYNC_EMAIL: "[email protected]",
APICORE_SYNC_PASSWORD: "pw",
};
describe("readIntegrationStatus", () => {
it("reports every integration as configured when all values are set", () => {
const status = readIntegrationStatus(FULL);
expect(status.every((s) => s.configured)).toBe(true);
expect(status.map((s) => s.key)).toEqual(["stripe", "stripeWebhook", "mail", "sso", "turnstile", "googleReviews", "dhl", "apicore"]);
});
it("names exactly the missing variables", () => {
const mail = readIntegrationStatus({ ...FULL, SMTP_USER: "", ENTRA_CLIENT_SECRET: " " }).find((s) => s.key === "mail");
expect(mail).toMatchObject({ configured: false, missing: ["SMTP_USER", "ENTRA_CLIENT_SECRET"] });
});
it("flags Stripe test keys", () => {
const stripe = readIntegrationStatus({ ...FULL, STRIPE_SECRET_KEY: "sk_test_abc", STRIPE_PUBLISHABLE_KEY: "pk_test_abc" }).find((s) => s.key === "stripe");
expect(stripe).toMatchObject({ configured: true, warning: "stripe-test-mode" });
});
it("flags mixed Stripe modes", () => {
const stripe = readIntegrationStatus({ ...FULL, STRIPE_PUBLISHABLE_KEY: "pk_test_abc" }).find((s) => s.key === "stripe");
expect(stripe?.warning).toBe("stripe-mixed-mode");
});
it("never returns a configured value", () => {
expect(JSON.stringify(readIntegrationStatus(FULL))).not.toMatch(/sk_live_abc|whsec_abc|noreply@|"pw"/);
});
it("marks shop-critical integrations as required", () => {
const required = readIntegrationStatus({}).filter((s) => s.required).map((s) => s.key);
expect(required).toEqual(["stripe", "stripeWebhook", "mail"]);
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Welche Anbindungen sind eingerichtet? Fuer den Reiter "System & Tests" in
* Admin → Einstellungen. Liefert NUR Namen fehlender Variablen und ob etwas
* gesetzt ist — nie einen Wert, auch keinen Teil davon (Ausnahme: der Modus
* test/live der Stripe-Schluessel, abgeleitet aus dem Praefix).
*/
export type IntegrationKey = "stripe" | "stripeWebhook" | "mail" | "sso" | "turnstile" | "googleReviews" | "dhl" | "apicore";
export type IntegrationWarning = "stripe-test-mode" | "stripe-mixed-mode";
export interface IntegrationStatus {
key: IntegrationKey;
configured: boolean;
/** Ohne diese Anbindung kann der Shop nicht verkaufen oder bestaetigen. */
required: boolean;
missing: string[];
warning?: IntegrationWarning;
}
type Env = Record<string, string | undefined>;
const INTEGRATIONS: { key: IntegrationKey; required: boolean; variables: string[] }[] = [
{ key: "stripe", required: true, variables: ["STRIPE_SECRET_KEY", "STRIPE_PUBLISHABLE_KEY"] },
{ key: "stripeWebhook", required: true, variables: ["STRIPE_WEBHOOK_SECRET"] },
{ key: "mail", required: true, variables: ["SMTP_USER", "ENTRA_TENANT_ID", "ENTRA_CLIENT_ID", "ENTRA_CLIENT_SECRET"] },
{ key: "sso", required: false, variables: ["ENTRA_TENANT_ID", "ENTRA_CLIENT_ID", "ENTRA_CLIENT_SECRET", "ENTRA_REDIRECT_URI"] },
{ key: "turnstile", required: false, variables: ["TURNSTILE_SECRET_KEY", "NEXT_PUBLIC_TURNSTILE_SITE_KEY"] },
{ key: "googleReviews", required: false, variables: ["GOOGLE_PLACES_API_KEY", "GOOGLE_PLACE_ID"] },
{ key: "dhl", required: false, variables: ["DHL_API_KEY", "DHL_API_SECRET", "DHL_GKP_USERNAME", "DHL_GKP_PASSWORD", "DHL_ACCOUNT_NUMBER"] },
{ key: "apicore", required: false, variables: ["APICORE_API_URL", "APICORE_SYNC_EMAIL", "APICORE_SYNC_PASSWORD"] },
];
function stripeMode(key: string): "live" | "test" | null {
if (/^(sk|rk|pk)_live_/.test(key)) return "live";
if (/^(sk|rk|pk)_test_/.test(key)) return "test";
return null;
}
function stripeWarning(env: Env): IntegrationWarning | undefined {
const secret = stripeMode(env.STRIPE_SECRET_KEY?.trim() ?? "");
const publishable = stripeMode(env.STRIPE_PUBLISHABLE_KEY?.trim() ?? "");
if (secret && publishable && secret !== publishable) return "stripe-mixed-mode";
if (secret === "test" || publishable === "test") return "stripe-test-mode";
return undefined;
}
export function readIntegrationStatus(env: Env = process.env): IntegrationStatus[] {
return INTEGRATIONS.map(({ key, required, variables }) => {
const missing = variables.filter((name) => !env[name]?.trim());
const status: IntegrationStatus = { key, required, configured: missing.length === 0, missing };
const warning = key === "stripe" && status.configured ? stripeWarning(env) : undefined;
return warning ? { ...status, warning } : status;
});
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { CONTENT_REGISTRY } from "./content-registry";
import { SETTINGS_TABS, parseSettingsTab, settingsPathForGroup } from "./settings-tabs";
describe("settings tabs", () => {
it("places every content group on exactly one tab", () => {
const groups = new Set(CONTENT_REGISTRY.map((f) => f.group));
for (const group of groups) {
expect(SETTINGS_TABS.filter((tab) => (tab.groups as readonly string[]).includes(group))).toHaveLength(1);
}
});
it("falls back to the first tab for unknown or missing values", () => {
expect(parseSettingsTab(undefined)).toBe("firma");
expect(parseSettingsTab("../../etc")).toBe("firma");
expect(parseSettingsTab(["system"])).toBe("firma");
expect(parseSettingsTab("system")).toBe("system");
});
it("returns to the tab of the saved group", () => {
expect(settingsPathForGroup("shipping")).toBe("/admin/einstellungen?reiter=firma");
expect(settingsPathForGroup("legal")).toBe("/admin/einstellungen?reiter=recht");
expect(settingsPathForGroup("evil?x=1")).toBe("/admin/einstellungen");
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { ContentFieldDef } from "@/lib/content-registry";
/**
* Reiter von Admin → Einstellungen und welche Inhaltsgruppen
* (lib/content-registry.ts) sie tragen. "system" hat keine Inhaltsfelder,
* sondern Anbindungsstatus und E-Mail-Test.
*/
export const SETTINGS_TABS = [
{ id: "firma", groups: ["company", "shipping"] },
{ id: "marke", groups: ["brand"] },
{ id: "startseite", groups: ["home"] },
{ id: "seiten", groups: ["pages"] },
{ id: "social", groups: ["social"] },
{ id: "recht", groups: ["legal"] },
{ id: "system", groups: [] },
] as const satisfies readonly { id: string; groups: readonly ContentFieldDef["group"][] }[];
export type SettingsTabId = (typeof SETTINGS_TABS)[number]["id"];
export const SETTINGS_PATH = "/admin/einstellungen";
/** Reiter aus ?reiter=…; alles Unbekannte (auch Mehrfachwerte) faellt auf den ersten Reiter zurueck. */
export function parseSettingsTab(value: string | string[] | undefined): SettingsTabId {
const match = SETTINGS_TABS.find((tab) => tab.id === value);
return match ? match.id : SETTINGS_TABS[0].id;
}
/**
* Ziel nach dem Speichern: derselbe Reiter. Der group-Wert kommt aus dem
* Formular und ist damit Nutzereingabe — nur bekannte Gruppen fuehren zu
* einem Reiter, alles andere zur Uebersicht.
*/
export function settingsPathForGroup(group: string): string {
const tab = SETTINGS_TABS.find((t) => (t.groups as readonly string[]).includes(group));
return tab ? `${SETTINGS_PATH}?reiter=${tab.id}` : SETTINGS_PATH;
}