feat: Security CSRF Fix, Logout optimierung, Footer Anpassung, Rechtstexteanpassung
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s
Docker Image bauen und veroeffentlichen / build-and-push (push) Failing after 1m33s
This commit is contained in:
1 parent
0753a3bc8a
commit
4d3100e9ba
28 files changed
+802
-121
No files matched your search
@@ -12,3 +12,5 @@ export * from "./actions/shipping";
|
||||
export * from "./actions/reviews";
|
||||
export * from "./actions/discount-codes";
|
||||
export * from "./actions/content";
|
||||
export * from "./actions/apicore-sync";
|
||||
export * from "./actions/dhl";
|
||||
@@ -11,6 +11,11 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
|
||||
const year = num(formData, "year");
|
||||
const type = str(formData, "type");
|
||||
const slug = `${type.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${year}`;
|
||||
// Checkbox: nur in formData vorhanden, wenn angehakt — fehlt sie ganz
|
||||
// (kein Feld im Formular), gilt "veroeffentlicht" als sicherer Default fuer
|
||||
// manuell im Admin-Bereich angelegte Ernten (bisheriges Verhalten vor
|
||||
// Einfuehrung von isPublished).
|
||||
const isPublished = !formData.has("isPublished") || formData.get("isPublished") === "on";
|
||||
|
||||
const harvest = await prisma.harvest.create({
|
||||
data: {
|
||||
@@ -20,6 +25,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
|
||||
harvestDate: new Date(str(formData, "harvestDate")),
|
||||
location: str(formData, "location") || "Altenmünster",
|
||||
description: str(formData, "description"),
|
||||
isPublished,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,12 +37,14 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
|
||||
});
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
revalidatePath("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
}
|
||||
|
||||
export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const isPublished = formData.get("isPublished") === "on";
|
||||
|
||||
await prisma.harvest.update({
|
||||
where: { id },
|
||||
@@ -46,12 +54,43 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
|
||||
harvestDate: new Date(str(formData, "harvestDate")),
|
||||
location: str(formData, "location") || "Altenmünster",
|
||||
description: str(formData, "description"),
|
||||
isPublished,
|
||||
},
|
||||
});
|
||||
|
||||
await logAdminAction(admin, { action: "harvest.update", entityType: "Harvest", entityId: id });
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
revalidatePath("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
}
|
||||
|
||||
/**
|
||||
* Isolierte "Veroeffentlichen"/"Verbergen"-Aktion, getrennt von
|
||||
* updateHarvestAction — so kann der Status per Knopfdruck direkt aus der
|
||||
* Ernten-Liste umgeschaltet werden, ohne durch das komplette Bearbeiten-
|
||||
* Formular zu muessen (insbesondere fuer den "veroeffentlichen"-Bestaetigungs-
|
||||
* schritt bei aus ApiCore synchronisierten Entwuerfen, siehe apicore-sync.ts).
|
||||
*/
|
||||
export async function toggleHarvestPublishedAction(formData: FormData): Promise<void> {
|
||||
const admin = await requireAdmin();
|
||||
const id = str(formData, "id");
|
||||
const isPublished = formData.get("isPublished") === "on";
|
||||
|
||||
const harvest = await prisma.harvest.update({
|
||||
where: { id },
|
||||
data: { isPublished },
|
||||
});
|
||||
|
||||
await logAdminAction(admin, {
|
||||
action: isPublished ? "harvest.publish" : "harvest.unpublish",
|
||||
entityType: "Harvest",
|
||||
entityId: id,
|
||||
detail: `${harvest.type} ${harvest.year}`,
|
||||
});
|
||||
|
||||
revalidatePath("/admin/ernten");
|
||||
revalidatePath("/ernten");
|
||||
redirect("/admin/ernten");
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,16 @@ import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatPrice, formatDate } from "@/lib/format";
|
||||
import { updateOrderStatusAction, updateShipmentTrackingAction } from "../../actions";
|
||||
import { updateOrderStatusAction, updateShipmentTrackingAction, createDhlLabelAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { isDhlConfigured } from "@/lib/dhl";
|
||||
|
||||
export default async function AdminOrderDetailPage(props: PageProps<"/admin/bestellungen/[id]">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const statusLabels = t.orderStatusBadge;
|
||||
const { id } = await props.params;
|
||||
const { error, message } = await props.searchParams;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
@@ -24,11 +26,19 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
},
|
||||
});
|
||||
if (!order) notFound();
|
||||
const dhlConfigured = isDhlConfigured();
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<BackLink href="/admin/bestellungen" label={t.adminOrderDetailPage.backToOrders} />
|
||||
|
||||
{error ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">{message}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl text-forest">{order.orderNumber}</h1>
|
||||
@@ -101,6 +111,26 @@ export default async function AdminOrderDetailPage(props: PageProps<"/admin/best
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.fulfillmentMethod === "VERSAND" && order.payment?.status === "SUCCEEDED" ? (
|
||||
<div className="bg-background border border-ink/10 p-6 mb-8">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.dhlHeading}</h2>
|
||||
{order.shipment?.trackingNumber ? (
|
||||
<p className="text-sm text-ink/70">
|
||||
{t.adminOrderDetailPage.dhlAlreadyCreated(order.shipment.trackingNumber)}
|
||||
</p>
|
||||
) : dhlConfigured ? (
|
||||
<form action={createDhlLabelAction}>
|
||||
<input type="hidden" name="orderId" value={order.id} />
|
||||
<button type="submit" className="bg-honey-dark text-cream px-4 py-2 text-sm">
|
||||
{t.adminOrderDetailPage.dhlCreateButton}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-ink/65">{t.adminOrderDetailPage.dhlNotConfigured}</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{order.fulfillmentMethod === "VERSAND" ? (
|
||||
<div className="bg-background border border-ink/10 p-6 mb-8">
|
||||
<h2 className="text-xs tracking-wide-caps text-ink/65 mb-4">{t.adminOrderDetailPage.trackingHeading}</h2>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { updateHarvestAction } from "../../actions";
|
||||
import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
@@ -17,6 +17,20 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
|
||||
<div className="max-w-xl">
|
||||
<BackLink href="/admin/ernten" label={t.adminHarvestEditPage.backToHarvests} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestEditPage.title}</h1>
|
||||
|
||||
{harvest.isPublished ? null : (
|
||||
<div className="mb-6 bg-honey/10 border border-honey/30 px-4 py-3 flex flex-col sm:flex-row sm:items-center gap-3 justify-between">
|
||||
<p className="text-sm text-honey-dark">{t.adminHarvestEditPage.draftNotice}</p>
|
||||
<form action={toggleHarvestPublishedAction}>
|
||||
<input type="hidden" name="id" value={harvest.id} />
|
||||
<input type="hidden" name="isPublished" value="on" />
|
||||
<button type="submit" className="bg-forest text-cream px-4 py-2 text-sm whitespace-nowrap">
|
||||
{t.adminHarvestEditPage.publishNow}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={updateHarvestAction} className="space-y-4 bg-background border border-ink/10 p-6">
|
||||
<input type="hidden" name="id" value={harvest.id} />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -68,6 +82,11 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt
|
||||
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
|
||||
{t.adminHarvestEditPage.isPublishedLabel}
|
||||
</label>
|
||||
<p className="text-xs text-ink/50">{t.adminHarvestEditPage.isPublishedHint}</p>
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminHarvestEditPage.save}
|
||||
</button>
|
||||
|
||||
@@ -2,12 +2,13 @@ import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BackLink } from "@/components/back-link";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { createHarvestAction } from "../actions";
|
||||
import { createHarvestAction, toggleHarvestPublishedAction, syncHarvestsFromApiCoreAction } from "../actions";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
|
||||
export default async function AdminHarvestsPage() {
|
||||
export default async function AdminHarvestsPage(props: PageProps<"/admin/ernten">) {
|
||||
const t = getDictionary(await getLocale());
|
||||
const { synced, updated, skipped, failed, syncError } = await props.searchParams;
|
||||
const harvests = await prisma.harvest.findMany({
|
||||
orderBy: [{ year: "desc" }, { harvestDate: "desc" }],
|
||||
include: { _count: { select: { batches: true, products: true } } },
|
||||
@@ -17,6 +18,18 @@ export default async function AdminHarvestsPage() {
|
||||
<div>
|
||||
<BackLink href="/admin" label={t.adminHarvestsPage.backToDashboard} />
|
||||
<h1 className="text-2xl text-forest mb-8">{t.adminHarvestsPage.title}</h1>
|
||||
|
||||
{synced !== undefined ? (
|
||||
<p className="mb-6 text-sm text-forest bg-forest/5 border border-forest/20 px-4 py-3">
|
||||
{t.adminHarvestsPage.syncResult(Number(synced), Number(updated ?? 0), Number(skipped ?? 0), Number(failed ?? 0))}
|
||||
</p>
|
||||
) : null}
|
||||
{syncError !== undefined ? (
|
||||
<p className="mb-6 text-sm text-red-700 bg-red-50 border border-red-200 px-4 py-3">
|
||||
{t.adminHarvestsPage.syncError} ({syncError})
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-x-auto bg-background border border-ink/10">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-left text-ink/65 border-b border-ink/10">
|
||||
@@ -27,6 +40,7 @@ export default async function AdminHarvestsPage() {
|
||||
<th className="p-4">{t.adminHarvestsPage.colLocation}</th>
|
||||
<th className="p-4">{t.adminHarvestsPage.colBatches}</th>
|
||||
<th className="p-4">{t.adminHarvestsPage.colProducts}</th>
|
||||
<th className="p-4">{t.adminHarvestsPage.colStatus}</th>
|
||||
<th className="p-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -40,9 +54,27 @@ export default async function AdminHarvestsPage() {
|
||||
<td className="p-4">{harvest._count.batches}</td>
|
||||
<td className="p-4">{harvest._count.products}</td>
|
||||
<td className="p-4">
|
||||
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline">
|
||||
<span
|
||||
className={
|
||||
harvest.isPublished
|
||||
? "text-forest bg-forest/10 px-2 py-1 text-xs"
|
||||
: "text-honey-dark bg-honey/10 px-2 py-1 text-xs"
|
||||
}
|
||||
>
|
||||
{harvest.isPublished ? t.adminHarvestsPage.statusPublished : t.adminHarvestsPage.statusDraft}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 whitespace-nowrap">
|
||||
<Link href={`/admin/ernten/${harvest.id}`} className="text-forest underline mr-4">
|
||||
{t.adminHarvestsPage.edit}
|
||||
</Link>
|
||||
<form action={toggleHarvestPublishedAction} className="inline">
|
||||
<input type="hidden" name="id" value={harvest.id} />
|
||||
{harvest.isPublished ? null : <input type="hidden" name="isPublished" value="on" />}
|
||||
<button type="submit" className="text-forest underline">
|
||||
{harvest.isPublished ? t.adminHarvestsPage.unpublish : t.adminHarvestsPage.publish}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -51,20 +83,36 @@ export default async function AdminHarvestsPage() {
|
||||
{harvests.length === 0 ? <p className="p-6 text-ink/65">{t.adminHarvestsPage.noHarvestsYet}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 bg-background border border-ink/10 p-6 max-w-xl">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
|
||||
<form action={createHarvestAction} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="type" label={t.adminHarvestsPage.typeLabel} required />
|
||||
<Field name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
|
||||
</div>
|
||||
<Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
|
||||
<Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
|
||||
<TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminHarvestsPage.create}
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-10 grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.newHarvestHeading}</h2>
|
||||
<form action={createHarvestAction} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field name="type" label={t.adminHarvestsPage.typeLabel} required />
|
||||
<Field name="year" label={t.adminHarvestsPage.yearLabel} type="number" required />
|
||||
</div>
|
||||
<Field name="harvestDate" label={t.adminHarvestsPage.harvestDateLabel} type="date" required />
|
||||
<Field name="location" label={t.adminHarvestsPage.locationLabel} defaultValue="Altenmünster" />
|
||||
<TextArea name="description" label={t.adminHarvestsPage.descriptionLabel} required />
|
||||
<label className="flex items-center gap-2 text-sm text-ink/70">
|
||||
<input type="checkbox" name="isPublished" defaultChecked />
|
||||
{t.adminHarvestsPage.isPublishedLabel}
|
||||
</label>
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminHarvestsPage.create}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-background border border-ink/10 p-6">
|
||||
<h2 className="text-lg text-forest mb-4">{t.adminHarvestsPage.syncHeading}</h2>
|
||||
<p className="text-sm text-ink/65 mb-4">{t.adminHarvestsPage.syncIntro}</p>
|
||||
<form action={syncHarvestsFromApiCoreAction}>
|
||||
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
|
||||
{t.adminHarvestsPage.syncButton}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ export default async function AdminInhaltePage(props: PageProps<"/admin/inhalte"
|
||||
const groups: { key: ContentFieldDef["group"]; heading: string }[] = [
|
||||
{ key: "company", heading: t.adminContentPage.groupCompany },
|
||||
{ key: "home", heading: t.adminContentPage.groupHome },
|
||||
{ key: "social", heading: t.adminContentPage.groupSocial },
|
||||
{ key: "legal", heading: t.adminContentPage.groupLegal },
|
||||
];
|
||||
|
||||
|
||||
@@ -3,8 +3,14 @@ import { prisma } from "@/lib/prisma";
|
||||
import { businessInquirySchema } from "@/lib/validation";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { isTrustedOrigin } from "@/lib/csrf";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Route-Handler haben keinen eingebauten CSRF-Schutz — siehe lib/csrf.ts.
|
||||
if (!isTrustedOrigin(request)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`business-inquiry:${ip}`, 5, 60 * 60 * 1000)) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -6,6 +6,7 @@ import { checkoutSchema } from "@/lib/validation";
|
||||
import { getProductsByIds } from "@/lib/data";
|
||||
import { getCurrentCustomer } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { isTrustedOrigin } from "@/lib/csrf";
|
||||
import { getShippingCostCents } from "@/lib/shipping";
|
||||
import { isDiscountCodeValid, calculateDiscountCents } from "@/lib/discount-calc";
|
||||
import { ensureBulkDiscountCode } from "@/lib/bulk-discount";
|
||||
@@ -23,6 +24,14 @@ function generateOrderNumber(): string {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Route-Handler haben (anders als Server Actions) keinen eingebauten
|
||||
// CSRF-Schutz — siehe lib/csrf.ts. Ohne diese Pruefung koennte eine fremde
|
||||
// Seite per gefaelschtem Cross-Site-POST Bestellungen unter dem Konto
|
||||
// einer eingeloggten Kundschaft anlegen und Lagerbestand reservieren.
|
||||
if (!isTrustedOrigin(request)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`checkout:${ip}`, 10, 60 * 60 * 1000)) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -7,9 +7,15 @@ import { sendMail } from "@/lib/mail";
|
||||
import { newsletterConfirmEmail } from "@/lib/email-templates";
|
||||
import { getBaseUrl } from "@/lib/url";
|
||||
import { verifyTurnstileToken } from "@/lib/turnstile";
|
||||
import { isTrustedOrigin } from "@/lib/csrf";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Route-Handler haben keinen eingebauten CSRF-Schutz — siehe lib/csrf.ts.
|
||||
if (!isTrustedOrigin(request)) {
|
||||
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = await getClientIp();
|
||||
if (!rateLimit(`newsletter:${ip}`, 5, 60 * 60 * 1000)) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -11,7 +11,8 @@ export default async function ImpressumPage() {
|
||||
const locale = await getLocale();
|
||||
const t = getDictionary(locale);
|
||||
const [company, raw] = await Promise.all([getCompanyInfo(), getContentValue("legal.impressum", locale)]);
|
||||
const [angabenSection, ustSection, streitSection] = parseSections(raw);
|
||||
const [angabenSection, ustSection, redaktionSection, haftungInhalteSection, haftungLinksSection, urheberrechtSection, streitSection] =
|
||||
parseSections(raw);
|
||||
|
||||
return (
|
||||
<LegalPage
|
||||
@@ -30,6 +31,10 @@ export default async function ImpressumPage() {
|
||||
paragraphs: [`${t.impressumPage.phoneLabel}: ${company.phone}`, `${t.impressumPage.emailLabel}: ${company.email}`],
|
||||
},
|
||||
ustSection,
|
||||
redaktionSection,
|
||||
haftungInhalteSection,
|
||||
haftungLinksSection,
|
||||
urheberrechtSection,
|
||||
streitSection,
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { createSession, getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
|
||||
import { getCurrentCustomer, verifyPassword, destroySession } from "@/lib/auth";
|
||||
import { rateLimit, getClientIp } from "@/lib/rate-limit";
|
||||
import { sendMail } from "@/lib/mail";
|
||||
import { securityAlertEmail } from "@/lib/email-templates";
|
||||
@@ -50,16 +50,22 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
|
||||
redirect("/konto?message=" + encodeURIComponent("Dein Konto wurde endgültig gelöscht."));
|
||||
}
|
||||
|
||||
/** Widerruft alle bestehenden Sessions (auch andere Geraete) und meldet nur das aktuelle Tab neu an. */
|
||||
/**
|
||||
* Widerruft alle bestehenden Sessions — auch das aktuell genutzte Geraet.
|
||||
* Bewusst KEIN Re-Login des aktuellen Tabs mehr (fruehere Variante): "alle
|
||||
* Geraete abmelden" muss auch die eigene, gerade aktive Sitzung beenden,
|
||||
* sonst waere ein gestohlenes/kompromittiertes aktuelles Session-Cookie von
|
||||
* dieser Aktion gar nicht betroffen.
|
||||
*/
|
||||
export async function logoutAllDevicesAction(): Promise<void> {
|
||||
const customer = await getCurrentCustomer();
|
||||
if (!customer) fail("/konto", "Bitte melde dich an.");
|
||||
|
||||
const updated = await prisma.customer.update({
|
||||
await prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: { sessionVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
await createSession(updated.id, updated.role, updated.sessionVersion);
|
||||
redirect("/konto?message=" + encodeURIComponent("Alle anderen Geräte wurden abgemeldet."));
|
||||
await destroySession();
|
||||
redirect("/konto?message=" + encodeURIComponent("Alle Geräte wurden abgemeldet. Bitte melde dich erneut an."));
|
||||
}
|
||||
@@ -80,6 +80,35 @@ export default async function KontoPage(props: PageProps<"/konto">) {
|
||||
<Button type="submit">{t.account.register}</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center gap-4 text-xs text-ink/50">
|
||||
<span className="h-px flex-1 bg-ink/10" />
|
||||
{t.account.ssoDivider}
|
||||
<span className="h-px flex-1 bg-ink/10" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
{/*
|
||||
Bewusst ein einfaches <a> statt LinkButton/next/link: der Klick
|
||||
muss eine ECHTE Browser-Top-Level-Navigation ausloesen, die dem
|
||||
302-Redirect dieser Route bis zu login.microsoftonline.com
|
||||
folgt. Next.js' <Link> navigiert clientseitig per fetch() —
|
||||
dieser Cross-Origin-Redirect-Sprung wuerde dann von der
|
||||
connect-src-CSP in proxy.ts blockiert (die nur "self" u.ae.
|
||||
erlaubt), waehrend eine echte Top-Level-Navigation von CSP
|
||||
connect-src gar nicht erst betroffen ist.
|
||||
Bewusst immer sichtbar, auch ohne konfiguriertes SSO (siehe
|
||||
.env.example, ENTRA_*) — der Link fuehrt in dem Fall zu einer
|
||||
klaren Fehlermeldung auf dieser Seite statt eines defekten
|
||||
Buttons (src/app/api/auth/entra/login/route.ts prueft die
|
||||
Konfiguration server-seitig, bevor irgendetwas passiert).
|
||||
*/}
|
||||
<a
|
||||
href="/api/auth/entra/login"
|
||||
className="inline-flex items-center justify-center gap-2 px-7 py-3 text-sm tracking-wide-caps transition-[color,background-color,border-color,transform] duration-300 active:scale-[0.97] bg-transparent text-forest border border-forest hover:bg-forest hover:text-cream"
|
||||
>
|
||||
{t.account.ssoButton}
|
||||
</a>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ import { Header, Footer, CartDrawer } from "@/components/site-chrome";
|
||||
import { CookieConsentBanner } from "@/components/cookie-consent";
|
||||
import { siteConfig } from "@/lib/site-config";
|
||||
import { getCompanyInfo } from "@/lib/company-info";
|
||||
import { getSocialLinks } from "@/lib/social-links";
|
||||
import { getLocale } from "@/i18n/locale";
|
||||
import { getDictionary } from "@/i18n/get-dictionary";
|
||||
import { I18nProvider } from "@/i18n/i18n-provider";
|
||||
@@ -41,7 +42,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const locale = await getLocale();
|
||||
const dict = getDictionary(locale);
|
||||
const company = await getCompanyInfo();
|
||||
const [company, socialLinks] = await Promise.all([getCompanyInfo(), getSocialLinks()]);
|
||||
|
||||
// LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt
|
||||
// auf jeder Seite einzeln) — hilft insbesondere bei lokaler Google-Suche
|
||||
@@ -105,7 +106,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
<main id="main-content" className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer companyInfo={company} />
|
||||
<Footer companyInfo={company} socialLinks={socialLinks} />
|
||||
<CartDrawer />
|
||||
<CookieConsentBanner />
|
||||
</I18nProvider>
|
||||
|
||||
Reference in new issue
Block a user