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
@@ -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
@@ -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 "[email protected]" -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:
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
+20
-1
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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