feat(konto): settings page for security and privacy

Adds /konto/einstellungen, which gathers two-factor authentication,
"sign out on all devices", the Erntepost consent, the data export
(Art. 15 GDPR) and account deletion (Art. 17 GDPR) in one place.
The account overview now links there through a "Settings" card
instead of listing these blocks inline. Server actions are unchanged
apart from redirecting failures and revalidating the new page.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-09-30 21:20:37 +02:00
1 parent 3bc0693e4d
commit 3107a02588
10 files changed
+215 -93

No files matched your search

+3 -3
View File
@@ -22,7 +22,7 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
if (!customer) return fail("/konto", "Bitte melde dich an.");
if (customer.role === "ADMIN") {
return fail("/konto", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
return fail("/konto/einstellungen", "Admin-Konten können nicht selbst gelöscht werden. Bitte kontaktiere einen anderen Admin.");
}
const ip = await getClientIp();
@@ -33,13 +33,13 @@ export async function deleteOwnAccountAction(formData: FormData): Promise<void>
ACCOUNT_PASSWORD_CONFIRM_WINDOW_MS
)
) {
return fail("/konto", "Zu viele Versuche. Bitte versuche es später erneut.");
return fail("/konto/einstellungen", "Zu viele Versuche. Bitte versuche es später erneut.");
}
const password = String(formData.get("password") ?? "");
const passwordValid = await verifyPassword(password, customer.passwordHash);
if (!passwordValid) {
return fail("/konto", "Passwort ist falsch.");
return fail("/konto/einstellungen", "Passwort ist falsch.");
}
const { subject, html, text } = await securityAlertEmail(await getLocale(), "accountDeleted");
+1 -1
View File
@@ -29,6 +29,6 @@ export async function toggleNewsletterAction(): Promise<void> {
});
}
revalidatePath("/konto");
revalidatePath("/konto/einstellungen");
await setFlash("success", "Deine Erntepost-Einstellung wurde gespeichert.");
}
+141
View File
@@ -0,0 +1,141 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Container, SectionHeading, Button, LinkButton } from "@/components/ui";
import { BackLink } from "@/components/back-link";
import { ShieldIcon } from "@/components/account-ui";
import { ConfirmForm } from "@/components/confirm-form";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { getCurrentCustomer } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { toggleNewsletterAction, logoutAllDevicesAction, deleteOwnAccountAction } from "../actions";
export const metadata: Metadata = { title: "Einstellungen" };
export const dynamic = "force-dynamic";
/**
* Buendelt alles, was das Konto selbst betrifft statt einer Bestellung:
* Sicherheit (Zwei-Faktor, Sitzungen) und Datenschutz (Einwilligung in die
* Erntepost, Auskunft nach Art. 15, Loeschung nach Art. 17 DSGVO). Die
* Aktionen selbst leben unveraendert in ../actions — diese Seite ist nur
* ihr gemeinsamer Ort.
*/
export default async function EinstellungenPage() {
const t = getDictionary(await getLocale());
const customer = await getCurrentCustomer();
if (!customer) redirect("/konto");
const newsletter = await prisma.newsletterSubscriber.findUnique({
where: { customerId: customer.id },
});
const isSubscribed = Boolean(newsletter && newsletter.unsubscribedAt === null);
return (
<div className="py-20">
<Container className="max-w-2xl">
<BackLink href="/konto" label={t.account.settings.backLabel} />
<SectionHeading level="h1" title={t.account.settings.title} subtitle={t.account.settings.subtitle} />
<section aria-labelledby="einstellungen-sicherheit" className="mt-12">
<h2 id="einstellungen-sicherheit" className="text-2xl text-heading mb-4">
{t.account.settings.securityHeading}
</h2>
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h3 className="flex items-center gap-2 text-lg text-heading">
<ShieldIcon />
{t.account.settings.twoFactorHeading}
</h3>
<p className="mt-1 text-sm text-ink/70">
{customer.twoFactorEnabled
? t.account.settings.twoFactorActiveDescription
: t.account.settings.twoFactorInactiveDescription}
</p>
</div>
<LinkButton
href="/konto/zwei-faktor"
variant={customer.twoFactorEnabled ? "secondary" : "primary"}
className="flex-shrink-0"
>
{customer.twoFactorEnabled ? t.account.settings.twoFactorManage : t.account.settings.twoFactorSetup}
</LinkButton>
</div>
<div className="mt-6 border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h3 className="text-lg text-heading">{t.account.logoutAllDevicesHeading}</h3>
<p className="mt-1 text-sm text-ink/70">{t.account.logoutAllDevicesDescription}</p>
</div>
<form action={logoutAllDevicesAction} className="flex-shrink-0">
<Button type="submit" variant="secondary">
{t.account.settings.logoutAllDevicesButton}
</Button>
</form>
</div>
</section>
<section aria-labelledby="einstellungen-datenschutz" className="mt-12">
<h2 id="einstellungen-datenschutz" className="text-2xl text-heading mb-4">
{t.account.privacy.heading}
</h2>
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h3 className="text-lg text-heading">{t.account.newsletterHeading}</h3>
<p className="mt-1 text-sm text-ink/70">
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
</p>
</div>
<form action={toggleNewsletterAction} className="flex-shrink-0">
<Button type="submit" variant="secondary">
{isSubscribed ? t.account.unsubscribe : t.account.subscribe}
</Button>
</form>
</div>
<div className="mt-6 border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h3 className="text-lg text-heading">{t.account.privacy.downloadDataHeading}</h3>
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
</div>
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
{t.account.privacy.downloadDataButton}
</LinkButton>
</div>
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
<h3 className="text-lg text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
{customer.role === "ADMIN" ? (
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountAdminBlocked}</p>
) : (
<>
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
<ConfirmForm
action={deleteOwnAccountAction}
confirmMessage={t.account.privacy.deleteAccountConfirm}
className="mt-4 flex flex-wrap items-end gap-3"
>
<label className="block text-sm">
<span className="text-ink/70">{t.account.privacy.deleteAccountPasswordLabel}</span>
<input
name="password"
type="password"
required
autoComplete="current-password"
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-red-400"
/>
</label>
<Button type="submit" variant="secondary" className="border-red-300 text-red-700 hover:bg-red-100">
{t.account.privacy.deleteAccountButton}
</Button>
</ConfirmForm>
</>
)}
</div>
</section>
</Container>
</div>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { noIndexPrivate } from "@/lib/seo";
// Der Kontobereich enthaelt ausschliesslich personenbezogene Seiten
// (Bestellungen, Adressen, Wunschliste, Zwei-Faktor-Einrichtung) sowie die
// (Bestellungen, Adressen, Wunschliste, Einstellungen, Zwei-Faktor-Einrichtung) sowie die
// Anmeldung selbst. Nichts davon gehoert in den Suchindex.
//
// Gesetzt einmal hier im Layout statt in jeder Unterseite: /konto hat aktuell
+7 -80
View File
@@ -1,23 +1,18 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Container, SectionHeading, Button, LinkButton } from "@/components/ui";
import { AccountNavCard, OrderIcon, AddressIcon, HeartIcon, ShieldIcon } from "@/components/account-ui";
import { Container, SectionHeading, Button } from "@/components/ui";
import { AccountNavCard, OrderIcon, AddressIcon, HeartIcon, ShieldIcon, SettingsIcon } from "@/components/account-ui";
import { TurnstileField } from "@/components/turnstile-widget";
import { PasswordField } from "@/components/password-field";
import { ConfirmForm } from "@/components/confirm-form";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { getCurrentCustomer } from "@/lib/auth";
import { isEntraConfigured } from "@/lib/entra";
import { prisma } from "@/lib/prisma";
import {
registerAction,
loginAction,
logoutAction,
toggleNewsletterAction,
logoutAllDevicesAction,
resendVerificationEmailAction,
deleteOwnAccountAction,
} from "./actions";
export const metadata: Metadata = { title: "Mein Konto" };
@@ -111,11 +106,6 @@ export default async function KontoPage() {
);
}
const newsletter = await prisma.newsletterSubscriber.findUnique({
where: { customerId: customer.id },
});
const isSubscribed = Boolean(newsletter && newsletter.unsubscribedAt === null);
return (
<div className="py-20">
<Container className="max-w-2xl">
@@ -155,10 +145,10 @@ export default async function KontoPage() {
description={t.account.navWishlist.description}
/>
<AccountNavCard
href="/konto/zwei-faktor"
icon={<ShieldIcon />}
title={t.account.navSecurity.title}
description={`${t.account.navSecurity.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSecurity.twoFactorActive : t.account.navSecurity.twoFactorInactive}`}
href="/konto/einstellungen"
icon={<SettingsIcon />}
title={t.account.navSettings.title}
description={`${t.account.navSettings.description} · ${t.account.navSettings.twoFactorLabel}: ${customer.twoFactorEnabled ? t.account.navSettings.twoFactorActive : t.account.navSettings.twoFactorInactive}`}
/>
{customer.role === "ADMIN" ? (
<AccountNavCard
@@ -172,74 +162,11 @@ export default async function KontoPage() {
) : null}
</div>
<div className="mt-10 border border-ink/10 p-6 flex items-center justify-between gap-4">
<div>
<h2 className="text-2xl text-heading">{t.account.newsletterHeading}</h2>
<p className="mt-1 text-sm text-ink/70">
{isSubscribed ? t.account.newsletterSubscribed : t.account.newsletterNotSubscribed}
</p>
</div>
<form action={toggleNewsletterAction}>
<Button type="submit" variant="secondary">
{isSubscribed ? t.account.unsubscribe : t.account.subscribe}
</Button>
</form>
</div>
<div className="mt-6 border border-ink/10 p-6 flex items-center justify-between gap-4">
<div>
<h2 className="text-2xl text-heading">{t.account.logoutAllDevicesHeading}</h2>
<p className="mt-1 text-sm text-ink/70">
{t.account.logoutAllDevicesDescription}
</p>
</div>
<form action={logoutAllDevicesAction}>
<Button type="submit" variant="secondary">
{t.account.logout}
</Button>
</form>
</div>
<form action={logoutAction} className="mt-6 mb-10">
<form action={logoutAction} className="mt-10">
<Button type="submit" variant="ghost">
{t.account.logout}
</Button>
</form>
<h2 className="text-2xl text-heading mb-4">{t.account.privacy.heading}</h2>
<div className="border border-ink/10 p-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div>
<h3 className="text-lg text-heading">{t.account.privacy.downloadDataHeading}</h3>
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.downloadDataDescription}</p>
</div>
<LinkButton href="/api/konto/datenexport" variant="secondary" className="flex-shrink-0">
{t.account.privacy.downloadDataButton}
</LinkButton>
</div>
<div className="mt-6 border border-red-200 bg-red-50/40 p-6">
<h3 className="text-lg text-red-700">{t.account.privacy.deleteAccountHeading}</h3>
<p className="mt-1 text-sm text-ink/70">{t.account.privacy.deleteAccountDescription}</p>
<ConfirmForm
action={deleteOwnAccountAction}
confirmMessage={t.account.privacy.deleteAccountConfirm}
className="mt-4 flex flex-wrap items-end gap-3"
>
<label className="block text-sm">
<span className="text-ink/70">{t.account.privacy.deleteAccountPasswordLabel}</span>
<input
name="password"
type="password"
required
autoComplete="current-password"
className="mt-1 w-full border border-ink/20 bg-background px-3 py-2 focus:outline-none focus:border-red-400"
/>
</label>
<Button type="submit" variant="secondary" className="border-red-300 text-red-700 hover:bg-red-100">
{t.account.privacy.deleteAccountButton}
</Button>
</ConfirmForm>
</div>
</Container>
</div>
);
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function ZweiFaktorPage() {
<div className="py-20">
<Container className="max-w-lg">
<FormPanel>
<BackLink href="/konto" label={t.twoFactorPage.backLabel} />
<BackLink href="/konto/einstellungen" label={t.twoFactorPage.backLabel} />
<SectionHeading
title={t.twoFactorPage.title}
subtitle={t.twoFactorPage.subtitle}