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

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