feat(admin): settings page with tabs, system checks and email test
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
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:
1 parent
b5311fae83
commit
de82ad0233
17 files changed
+654
-288
No files matched your search
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user