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

+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;
}