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}
+10
View File
@@ -265,6 +265,16 @@ export function HeartIcon() {
);
}
export function SettingsIcon() {
return (
<svg {...ICON_PROPS}>
<path d="M4 7h10M18 7h2M4 17h2M10 17h10" />
<circle cx="16" cy="7" r="2" />
<circle cx="8" cy="17" r="2" />
</svg>
);
}
export function ShieldIcon() {
return (
<svg {...ICON_PROPS}>
+16 -3
View File
@@ -732,7 +732,7 @@ export const de: Dictionary = {
twoFactorPage: {
title: "Zwei-Faktor-Authentifizierung",
subtitle: "Schützt dein Konto zusätzlich zum Passwort mit einem Code aus einer Authenticator-App.",
backLabel: "Zurück zur Übersicht",
backLabel: "Zurück zu den Einstellungen",
activeStatus: (count) => `Zwei-Faktor-Authentifizierung ist aktiv. Noch ${count} Backup-Code${count === 1 ? "" : "s"} verfügbar.`,
disableHeading: "Deaktivieren",
passwordConfirmLabel: "Passwort zur Bestätigung",
@@ -1631,8 +1631,9 @@ export const de: Dictionary = {
navOrders: { title: "Bestellungen", description: "Bestellhistorie, Versandstatus und Rechnungen" },
navAddresses: { title: "Adressen", description: "Lieferadressen verwalten" },
navWishlist: { title: "Wunschliste", description: "Gemerkte Produkte ansehen" },
navSecurity: {
title: "Sicherheit",
navSettings: {
title: "Einstellungen",
description: "Sicherheit und Datenschutz",
twoFactorLabel: "Zwei-Faktor-Authentifizierung",
twoFactorActive: "aktiv",
twoFactorInactive: "nicht aktiv",
@@ -1646,6 +1647,18 @@ export const de: Dictionary = {
logoutAllDevicesHeading: "Alle Geräte abmelden",
logoutAllDevicesDescription: "Beendet alle aktiven Sitzungen, auch dieses Gerät. Du musst dich danach neu anmelden.",
logout: "Abmelden",
settings: {
title: "Einstellungen",
subtitle: "Sicherheit deines Kontos und deine Daten an einem Ort.",
backLabel: "Zurück zu meinem Konto",
securityHeading: "Sicherheit",
twoFactorHeading: "Zwei-Faktor-Authentifizierung",
twoFactorActiveDescription: "Aktiv. Bei der Anmeldung fragen wir zusätzlich einen Code aus deiner Authenticator-App ab.",
twoFactorInactiveDescription: "Nicht aktiv. Wir empfehlen, dein Konto zusätzlich mit einem Code aus einer Authenticator-App zu schützen.",
twoFactorManage: "Verwalten",
twoFactorSetup: "Einrichten",
logoutAllDevicesButton: "Überall abmelden",
},
privacy: {
heading: "Datenschutz",
downloadDataHeading: "Meine Daten herunterladen",
+16 -3
View File
@@ -708,7 +708,7 @@ export const en: Dictionary = {
twoFactorPage: {
title: "Two-Factor Authentication",
subtitle: "Protects your account beyond just a password, with a code from an authenticator app.",
backLabel: "Back to overview",
backLabel: "Back to settings",
activeStatus: (count) => `Two-factor authentication is active. ${count} backup code${count === 1 ? "" : "s"} remaining.`,
disableHeading: "Disable",
passwordConfirmLabel: "Password to confirm",
@@ -1605,8 +1605,9 @@ export const en: Dictionary = {
navOrders: { title: "Orders", description: "Order history, shipping status, and invoices" },
navAddresses: { title: "Addresses", description: "Manage delivery addresses" },
navWishlist: { title: "Wishlist", description: "View saved products" },
navSecurity: {
title: "Security",
navSettings: {
title: "Settings",
description: "Security and privacy",
twoFactorLabel: "Two-factor authentication",
twoFactorActive: "active",
twoFactorInactive: "not active",
@@ -1620,6 +1621,18 @@ export const en: Dictionary = {
logoutAllDevicesHeading: "Log out all devices",
logoutAllDevicesDescription: "Ends all active sessions, including this device. You'll need to sign in again.",
logout: "Log out",
settings: {
title: "Settings",
subtitle: "Your account security and your data in one place.",
backLabel: "Back to my account",
securityHeading: "Security",
twoFactorHeading: "Two-factor authentication",
twoFactorActiveDescription: "Active. When you sign in, we also ask for a code from your authenticator app.",
twoFactorInactiveDescription: "Not active. We recommend protecting your account with an additional code from an authenticator app.",
twoFactorManage: "Manage",
twoFactorSetup: "Set up",
logoutAllDevicesButton: "Sign out everywhere",
},
privacy: {
heading: "Privacy",
downloadDataHeading: "Download my data",
+19 -1
View File
@@ -1167,7 +1167,13 @@ export interface Dictionary {
navOrders: { title: string; description: string };
navAddresses: { title: string; description: string };
navWishlist: { title: string; description: string };
navSecurity: { title: string; twoFactorLabel: string; twoFactorActive: string; twoFactorInactive: string };
navSettings: {
title: string;
description: string;
twoFactorLabel: string;
twoFactorActive: string;
twoFactorInactive: string;
};
navAdmin: { title: string; description: string };
newsletterHeading: string;
newsletterSubscribed: string;
@@ -1177,6 +1183,18 @@ export interface Dictionary {
logoutAllDevicesHeading: string;
logoutAllDevicesDescription: string;
logout: string;
settings: {
title: string;
subtitle: string;
backLabel: string;
securityHeading: string;
twoFactorHeading: string;
twoFactorActiveDescription: string;
twoFactorInactiveDescription: string;
twoFactorManage: string;
twoFactorSetup: string;
logoutAllDevicesButton: string;
};
privacy: {
heading: string;
downloadDataHeading: string;