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.
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):
@@ -278,7 +278,7 @@ Ob der Versand funktioniert, zeigt **Admin → E-Mail-Test**: Token, Anmeldung,
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.
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.
**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.
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.
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.
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.
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 |
| **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
### Mailversand (Anzeige in **Admin → E-Mail-Test**)
### Mailversand (Anzeige in **Admin → Einstellungen → System & Tests**)
| 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? |
| „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 |
| „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:
+4 -3
View File
@@ -8,6 +8,7 @@ import { isValidUploadPath, isValidVideoUploadPath } from "@/lib/uploads";
import { requireAdmin } from "./_shared";
import { fail, succeed } from "@/lib/flash";
import { normalizeContactEmail } from "@/lib/contact-email";
import { settingsPathForGroup } from "@/lib/settings-tabs";
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
* verarbeitet einfach, was tatsaechlich in formData vorhanden ist. Nicht
* 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> {
const admin = await requireAdmin();
@@ -59,7 +60,7 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
// Impressum ein Kontaktweg, der ins Leere fuehrt.
if (def.key === COMPANY_EMAIL_KEY && normalizeContactEmail(valueDe) === null) {
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."
);
}
@@ -85,5 +86,5 @@ export async function updateSiteContentAction(formData: FormData): Promise<void>
});
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 { AdminPageHeader } from "@/components/admin-list";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { SmtpTestPanel } from "./smtp-test-panel";
import { redirect } from "next/navigation";
import { SETTINGS_PATH } from "@/lib/settings-tabs";
export default async function AdminEmailTestPage() {
const t = getDictionary(await getLocale());
return (
<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>
);
/** Der E-Mail-Test liegt jetzt in Admin → Einstellungen → System & Tests. */
export default function AdminEmailTestRedirect(): never {
redirect(`${SETTINGS_PATH}?reiter=system`);
}
@@ -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 { Button } from "@/components/ui";
import { useI18n } from "@/i18n/i18n-provider";
import { runSmtpTestAction, type SmtpTestState } from "./actions";
import { runSmtpTestAction, type SmtpTestState } from "./smtp-test-actions";
export function SmtpTestPanel() {
const { t } = useI18n();
@@ -52,6 +52,25 @@ export function SmtpTestPanel() {
</div>
</div>
) : 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>
);
+5 -253
View File
@@ -1,255 +1,7 @@
import { prisma } from "@/lib/prisma";
import { BackLink } from "@/components/back-link";
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";
import { redirect } from "next/navigation";
import { SETTINGS_PATH } from "@/lib/settings-tabs";
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,
};
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>
);
/** Frueher "Inhalte" — jetzt Teil von Admin → Einstellungen. Alte Lesezeichen fuehren weiter. */
export default function AdminInhalteRedirect(): never {
redirect(SETTINGS_PATH);
}
+1 -2
View File
@@ -57,9 +57,8 @@ function getAdminNavGroups(t: Dictionary): { label: string | null; items: NavIte
{
label: t.adminShell.groupSite,
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/e-mail-test", label: t.adminShell.navEmailTest },
],
},
];
+36 -3
View File
@@ -1269,6 +1269,11 @@ export const de: Dictionary = {
detailLabel: "Meldung des Mailservers",
configLabel: "Verwendete Einstellungen",
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",
},
adminWithdrawalsPage: {
@@ -1442,8 +1447,37 @@ export const de: Dictionary = {
chartTooltip: (day, value) => `${day}: ${value}`,
},
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",
title: "Inhalte",
title: "Einstellungen",
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.",
sectionImages: "Bilder",
@@ -1510,8 +1544,7 @@ export const de: Dictionary = {
navVouchers: "Gutscheine",
navReviews: "Bewertungen",
navAuditLog: "Audit-Log",
navEmailTest: "E-Mail-Test",
navContent: "Inhalte",
navContent: "Einstellungen",
navAriaLabel: "Admin-Navigation",
mobileNavAriaLabel: "Mobile Admin-Navigation",
openMenu: "Menü öffnen",
+36 -3
View File
@@ -1243,6 +1243,11 @@ export const en: Dictionary = {
detailLabel: "Mail server message",
configLabel: "Settings used",
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",
},
adminWithdrawalsPage: {
@@ -1416,8 +1421,37 @@ export const en: Dictionary = {
chartTooltip: (day, value) => `${day}: ${value}`,
},
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",
title: "Content",
title: "Settings",
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.",
sectionImages: "Images",
@@ -1484,8 +1518,7 @@ export const en: Dictionary = {
navVouchers: "Vouchers",
navReviews: "Reviews",
navAuditLog: "Audit log",
navEmailTest: "Email test",
navContent: "Content",
navContent: "Settings",
navAriaLabel: "Admin navigation",
mobileNavAriaLabel: "Mobile admin navigation",
openMenu: "Open menu",
+25 -1
View File
@@ -830,6 +830,11 @@ export interface Dictionary {
detailLabel: string;
configLabel: string;
authModeOAuth: string;
networkHeading: string;
networkSmtp: string;
networkEntra: string;
networkOk: string;
networkFailed: string;
missingLabel: string;
};
adminWithdrawalsPage: {
@@ -996,6 +1001,26 @@ export interface Dictionary {
chartTooltip: (day: string, value: string) => string;
};
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;
title: string;
intro: string;
@@ -1059,7 +1084,6 @@ export interface Dictionary {
navVouchers: string;
navReviews: string;
navAuditLog: string;
navEmailTest: string;
navContent: string;
navAriaLabel: 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;
}